dom
packageAPI reference for the dom
package.
Imports
(15)ScheduleRender
ScheduleRender updates the DOM of the specified component after a delay.
Parameters
func ScheduleRender(componentID string, html string, delay time.Duration)
{
sched.Lock()
defer sched.Unlock()
if t, ok := sched.timers[componentID]; ok {
t.Stop()
}
sched.timers[componentID] = time.AfterFunc(delay, func() {
UpdateDOM(componentID, html)
sched.Lock()
delete(sched.timers, componentID)
sched.Unlock()
})
}
CreateElement
CreateElement returns a new element with the given tag name.
Parameters
Returns
func CreateElement(tag string) Element
{ return Doc().CreateElement(tag) }
Uses
From
From wraps a raw value into the typed element API. Elements that reach
application code from outside the query helpers (an event target, a NodeList
entry, a node returned by a browser API) have no other way in, and without
it callers fall back to getAttribute/classList/closest by hand.
func From(v js.Value) Element
{ return Element{v} }
Uses
ByID
ByID fetches an element by its id attribute.
Parameters
Returns
func ByID(id string) Element
{ return Doc().ByID(id) }
Uses
Query
Query returns the first element matching the CSS selector.
Parameters
Returns
func Query(selector string) Element
{ return Doc().Query(selector) }
Uses
QueryAll
QueryAll returns all elements matching the CSS selector.
Parameters
Returns
func QueryAll(selector string) Element
{ return Doc().QueryAll(selector) }
Uses
ByClass
ByClass returns all elements with the given class name.
Parameters
Returns
func ByClass(name string) Element
{ return Doc().ByClass(name) }
Uses
ByTag
ByTag returns all elements with the given tag name.
Parameters
Returns
func ByTag(tag string) Element
{ return Doc().ByTag(tag) }
Uses
SetInnerHTML
SetInnerHTML replaces an element’s children with the provided HTML string.
Parameters
func SetInnerHTML(el Element, html string)
{ el.SetHTML(html) }
Uses
Text
Text returns an element’s text content.
Parameters
Returns
func Text(el Element) string
{ return el.Text() }
Uses
SetText
SetText sets an element’s text content.
Parameters
func SetText(el Element, text string)
{ el.SetText(text) }
Uses
Attr
Attr retrieves the value of an attribute or an empty string if unset.
Parameters
Returns
func Attr(el Element, name string) string
{ return el.Attr(name) }
Uses
SetAttr
SetAttr sets the value of an attribute on the element.
Parameters
func SetAttr(el Element, name, value string)
{ el.SetAttr(name, value) }
Uses
AddClass
AddClass adds a class to the element’s class list.
Parameters
func AddClass(el Element, class string)
{ el.AddClass(class) }
Uses
RemoveClass
RemoveClass removes a class from the element’s class list.
Parameters
func RemoveClass(el Element, class string)
{ el.RemoveClass(class) }
Uses
HasClass
HasClass reports whether the element has the specified class.
Parameters
Returns
func HasClass(el Element, class string) bool
{ return el.HasClass(class) }
Uses
ToggleClass
ToggleClass toggles the presence of a class on the element’s class list.
Parameters
func ToggleClass(el Element, class string)
{ el.ToggleClass(class) }
Uses
SetStyle
SetStyle sets an inline style property on the element.
Parameters
func SetStyle(el Element, prop, value string)
{ el.SetStyle(prop, value) }
Uses
addInputBindingStop
Parameters
func addInputBindingStop(componentID string, stop func())
{
inputBindingStopsMu.Lock()
inputBindingStops[componentID] = append(inputBindingStops[componentID], stop)
inputBindingStopsMu.Unlock()
}
ReleaseInputBindings
ReleaseInputBindings stops all input listeners registered for a component.
UpdateDOM calls it before rebinding and core calls it on unmount.
Parameters
func ReleaseInputBindings(componentID string)
{
inputBindingStopsMu.Lock()
stops := inputBindingStops[componentID]
delete(inputBindingStops, componentID)
inputBindingStopsMu.Unlock()
for _, stop := range stops {
stop()
}
}
RegisterSignal
RegisterSignal associates a signal with a component so inputs can bind to it.
Parameters
func RegisterSignal(componentID, name string, sig any)
{
componentSignalsMu.Lock()
if componentSignals[componentID] == nil {
componentSignals[componentID] = make(map[string]any)
}
componentSignals[componentID][name] = sig
componentSignalsMu.Unlock()
}
RemoveComponentSignals
RemoveComponentSignals cleans up signals for a component on unmount.
Parameters
func RemoveComponentSignals(componentID string)
{
componentSignalsMu.Lock()
delete(componentSignals, componentID)
componentSignalsMu.Unlock()
}
getSignal
Parameters
Returns
func getSignal(componentID, name string) any
{
componentSignalsMu.RLock()
defer componentSignalsMu.RUnlock()
if m, ok := componentSignals[componentID]; ok {
return m[name]
}
return nil
}
SnapshotComponentSignals
SnapshotComponentSignals returns a copy of the signals registered for a component.
Parameters
Returns
func SnapshotComponentSignals(componentID string) map[string]any
{
componentSignalsMu.RLock()
defer componentSignalsMu.RUnlock()
if signals, ok := componentSignals[componentID]; ok {
clone := make(map[string]any, len(signals))
for k, v := range signals {
clone[k] = v
}
return clone
}
return nil
}
recoveredDOMPanic
type recoveredDOMPanic struct
Methods
Fields
| Name | Type | Description |
|---|---|---|
| value | any | |
| stack | []byte |
recoverDOMUpdate
Parameters
func recoverDOMUpdate(componentID string)
{
if recovered := recover(); recovered != nil {
panicValue := recoveredDOMPanic{value: recovered, stack: debug.Stack()}
if OnHandlerPanic == nil {
log.Printf("[rfw] recovered DOM update panic for %s: %v\n%s", componentID, recovered, panicValue.stack)
return
}
func() {
defer func() {
if hookPanic := recover(); hookPanic != nil {
log.Printf("[rfw] DOM panic reporter failed: %v", hookPanic)
log.Printf("[rfw] recovered DOM update panic for %s: %v\n%s", componentID, recovered, panicValue.stack)
}
}()
OnHandlerPanic(panicValue, "DOM update: "+componentID)
}()
}
}
ComponentRoot
ComponentRoot returns the DOM root element for a component by its ID.
Falls back to #app if id is empty or element not found.
Parameters
Returns
func ComponentRoot(id string) Element
{
doc := Doc()
if id == "" {
return doc.ByID("app")
}
el := doc.Query(fmt.Sprintf("[data-component-id='%s']", id))
if el.IsNull() || el.IsUndefined() {
return doc.ByID("app")
}
return el
}
Uses
UpdateDOM
UpdateDOM patches the DOM of the specified component with the provided
HTML string, resolving the target via typed Document/Element wrappers.
Parameters
func UpdateDOM(componentID string, html string)
{
defer recoverDOMUpdate(componentID)
element := ComponentRoot(componentID)
if element.IsNull() || element.IsUndefined() {
return
}
activeForm := captureActiveFormState(element.Value)
// Diff-patch only when the resolved element is the component's OWN root: that
// is an in-place reactive update, where patching preserves focus/selection.
// Otherwise the target is the #app fallback (a fresh mount or a route change,
// since ComponentRoot falls back to #app when the component root is not yet
// in the DOM). There, positionally diffing two different <root> trees leaves
// stale nodes from the previous component, so replace wholesale instead.
elID := element.Call("getAttribute", "data-component-id")
if componentID != "" && elID.Truthy() && elID.String() == componentID {
patchInnerHTML(element.Value, html)
} else {
element.Set("innerHTML", html)
recordRenderedTree(element.Value)
}
if TemplateHook != nil {
TemplateHook(componentID, html)
}
// Release the listeners of the previous render: rebinding below attaches
// fresh ones and stale listeners on replaced nodes would leak.
ReleaseInputBindings(componentID)
BindStoreInputsForComponent(componentID, element.Value)
BindSignalInputs(componentID, element.Value)
BindASTStoreInputs(componentID, element.Value)
BindASTSignalInputs(componentID, element.Value)
activeForm.restore()
UpdateLifecycleHooks(componentID)
}
UpdateMountedDOM
UpdateMountedDOM patches a component’s subtree only when its own root is in
the DOM. Reactive updates (store/signal changes) go through here: a change
hitting a component that is not mounted yet (a constructor-time Set) or not
anymore must be a no-op, not a wholesale replacement of the #app fallback.
Parameters
func UpdateMountedDOM(componentID, html string)
{
el := ComponentRoot(componentID)
if el.IsNull() || el.IsUndefined() {
return
}
id := el.Call("getAttribute", "data-component-id")
if !id.Truthy() || id.String() != componentID {
return
}
UpdateDOM(componentID, html)
}
UpdateDOMIn
UpdateDOMIn renders html into an explicit target element (the router
outlet). The subtree is replaced wholesale: across different component
trees a positional diff would leave stale nodes behind.
Parameters
func UpdateDOMIn(target Element, componentID, html string)
{
defer recoverDOMUpdate(componentID)
if target.IsNull() || target.IsUndefined() {
return
}
target.Set("innerHTML", html)
recordRenderedTree(target.Value)
if TemplateHook != nil {
TemplateHook(componentID, html)
}
ReleaseInputBindings(componentID)
BindStoreInputsForComponent(componentID, target.Value)
BindSignalInputs(componentID, target.Value)
BindASTStoreInputs(componentID, target.Value)
BindASTSignalInputs(componentID, target.Value)
UpdateLifecycleHooks(componentID)
}
Uses
BindASTStoreInputs
BindASTStoreInputs binds input elements that have data-bind-store attributes
(emitted by the AST renderer) to their store variables.
Parameters
func BindASTStoreInputs(componentID string, element js.Value)
{
inputs := element.Call("querySelectorAll", "[data-bind-store]")
for i := 0; i < inputs.Length(); i++ {
input := inputs.Index(i)
if !componentOwnsElement(componentID, input) {
continue
}
binding := input.Call("getAttribute", "data-bind-store").String()
parts := strings.Split(binding, ".")
if len(parts) != 3 {
continue
}
module, storeName, key := parts[0], parts[1], parts[2]
store := state.GlobalStoreManager.GetStore(module, storeName)
if store == nil {
continue
}
if StoreBindingHook != nil && componentID != "" {
StoreBindingHook(componentID, module, storeName, key)
}
storeValue := store.Get(key)
tag := strings.ToLower(input.Get("tagName").String())
if tag == "input" {
inputType := input.Get("type").String()
if inputType == "checkbox" {
if b, ok := storeValue.(bool); ok {
setCheckedIfChanged(input, b)
}
ch, stop := events.Listen("change", input)
addInputBindingStop(componentID, stop)
go func(in js.Value, st *state.Store, k string) {
for range ch {
st.Set(k, in.Get("checked").Bool())
}
}(input, store, key)
continue
}
}
if storeValue == nil {
storeValue = ""
}
setValueIfChanged(input, fmt.Sprintf("%v", storeValue))
ch, stop := events.Listen("input", input)
addInputBindingStop(componentID, stop)
go func(in js.Value, st *state.Store, k string) {
for event := range ch {
if inputEventIsComposing(event) {
continue
}
st.Set(k, in.Get("value").String())
}
}(input, store, key)
}
}
BindASTSignalInputs
BindASTSignalInputs binds input elements that have data-bind-signal attributes
(emitted by the AST renderer) to their signals.
Parameters
func BindASTSignalInputs(componentID string, element js.Value)
{
inputs := element.Call("querySelectorAll", "[data-bind-signal]")
for i := 0; i < inputs.Length(); i++ {
input := inputs.Index(i)
if !componentOwnsElement(componentID, input) {
continue
}
name := input.Call("getAttribute", "data-bind-signal").String()
sig := getSignal(componentID, name)
if sig == nil {
continue
}
tag := strings.ToLower(input.Get("tagName").String())
if tag == "input" {
inputType := input.Get("type").String()
if inputType == "checkbox" {
if s, ok := sig.(interface {
Read() any
Set(bool)
}); ok {
if b, ok := s.Read().(bool); ok {
setCheckedIfChanged(input, b)
}
ch, stop := events.Listen("change", input)
addInputBindingStop(componentID, stop)
go func(in js.Value, sg interface{ Set(bool) }) {
for range ch {
sg.Set(in.Get("checked").Bool())
}
}(input, s)
continue
}
}
}
if s, ok := sig.(interface {
Read() any
Set(string)
}); ok {
setValueIfChanged(input, fmt.Sprintf("%v", s.Read()))
ch, stop := events.Listen("input", input)
addInputBindingStop(componentID, stop)
go func(in js.Value, sg interface{ Set(string) }) {
for event := range ch {
if inputEventIsComposing(event) {
continue
}
sg.Set(in.Get("value").String())
}
}(input, s)
}
}
}
BindStoreInputsForComponent
BindStoreInputsForComponent binds input elements to store variables while
providing the component context for runtime hooks.
Parameters
func BindStoreInputsForComponent(componentID string, element js.Value)
{
inputs := element.Call("querySelectorAll", "input, select, textarea")
for i := 0; i < inputs.Length(); i++ {
input := inputs.Index(i)
if !componentOwnsElement(componentID, input) {
continue
}
valueAttr := ""
if input.Call("hasAttribute", "value").Bool() {
valueAttr = input.Call("getAttribute", "value").String()
}
checkedAttr := ""
if input.Call("hasAttribute", "checked").Bool() {
checkedAttr = input.Call("getAttribute", "checked").String()
}
re := reStoreWrite
valueMatch := re.FindStringSubmatch(valueAttr)
checkedMatch := re.FindStringSubmatch(checkedAttr)
var module, storeName, key string
var usesChecked bool
if len(valueMatch) == 4 {
module, storeName, key = valueMatch[1], valueMatch[2], valueMatch[3]
} else if len(checkedMatch) == 4 {
module, storeName, key = checkedMatch[1], checkedMatch[2], checkedMatch[3]
usesChecked = true
} else {
continue
}
store := state.GlobalStoreManager.GetStore(module, storeName)
if store == nil {
continue
}
if StoreBindingHook != nil && componentID != "" {
StoreBindingHook(componentID, module, storeName, key)
}
storeValue := store.Get(key)
if usesChecked {
boolVal, _ := storeValue.(bool)
setCheckedIfChanged(input, boolVal)
ch, stop := events.Listen("change", input)
addInputBindingStop(componentID, stop)
go func(in js.Value, st *state.Store, k string) {
for range ch {
st.Set(k, in.Get("checked").Bool())
}
}(input, store, key)
continue
}
if storeValue == nil {
storeValue = ""
}
setValueIfChanged(input, fmt.Sprintf("%v", storeValue))
ch, stop := events.Listen("input", input)
addInputBindingStop(componentID, stop)
go func(in js.Value, st *state.Store, k string) {
for event := range ch {
if inputEventIsComposing(event) {
continue
}
st.Set(k, in.Get("value").String())
}
}(input, store, key)
}
}
BindStoreInputs
BindStoreInputs binds input elements to store variables.
Parameters
func BindStoreInputs(element js.Value)
{
BindStoreInputsForComponent("", element)
}
BindSignalInputs
BindSignalInputs binds input elements to local component signals.
Parameters
func BindSignalInputs(componentID string, element js.Value)
{
inputs := element.Call("querySelectorAll", "input, select, textarea")
for i := 0; i < inputs.Length(); i++ {
input := inputs.Index(i)
if !componentOwnsElement(componentID, input) {
continue
}
valueAttr := ""
if input.Call("hasAttribute", "value").Bool() {
valueAttr = input.Call("getAttribute", "value").String()
}
checkedAttr := ""
if input.Call("hasAttribute", "checked").Bool() {
checkedAttr = input.Call("getAttribute", "checked").String()
}
re := reSignalWrite
valueMatch := re.FindStringSubmatch(valueAttr)
checkedMatch := re.FindStringSubmatch(checkedAttr)
var name string
var usesChecked bool
if len(valueMatch) == 2 {
name = valueMatch[1]
} else if len(checkedMatch) == 2 {
name = checkedMatch[1]
usesChecked = true
} else {
continue
}
sig := getSignal(componentID, name)
if sig == nil {
continue
}
if usesChecked {
if s, ok := sig.(interface {
Read() any
Set(bool)
}); ok {
if b, ok := s.Read().(bool); ok {
setCheckedIfChanged(input, b)
}
ch, stop := events.Listen("change", input)
addInputBindingStop(componentID, stop)
go func(in js.Value, sg interface{ Set(bool) }) {
for range ch {
sg.Set(in.Get("checked").Bool())
}
}(input, s)
}
continue
}
if s, ok := sig.(interface {
Read() any
Set(string)
}); ok {
setValueIfChanged(input, fmt.Sprintf("%v", s.Read()))
ch, stop := events.Listen("input", input)
addInputBindingStop(componentID, stop)
go func(in js.Value, sg interface{ Set(string) }) {
for event := range ch {
if inputEventIsComposing(event) {
continue
}
sg.Set(in.Get("value").String())
}
}(input, s)
}
}
}
componentOwnsElement
Parameters
Returns
func componentOwnsElement(componentID string, element js.Value) bool
{
if componentID == "" {
return true
}
root := element.Call("closest", "[data-component-id]")
return root.Truthy() && attribute(root, "data-component-id") == componentID
}
setValueIfChanged
Parameters
func setValueIfChanged(element js.Value, value string)
{
if element.Get("value").String() != value {
element.Set("value", value)
}
}
setCheckedIfChanged
Parameters
func setCheckedIfChanged(element js.Value, checked bool)
{
if element.Get("checked").Bool() != checked {
element.Set("checked", checked)
}
}
inputEventIsComposing
Parameters
Returns
func inputEventIsComposing(event js.Value) bool
{
value := event.Get("isComposing")
return value.Type() == js.TypeBoolean && value.Bool()
}
TestUpdateDOMSkipsNonElementNodes
Ensure UpdateDOM handles nodes without attributes (e.g. comments) without panicking.
Parameters
func TestUpdateDOMSkipsNonElementNodes(_ *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("div")
root.Set("id", "root")
body.Call("appendChild", root.Value)
defer root.Call("remove")
SetInnerHTML(root, "<!--old-->")
UpdateDOM("root", "<!--new-->")
}
TestPatchFocusedNumberInputPreservesLiveValue
Parameters
func TestPatchFocusedNumberInputPreservesLiveValue(t *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("root")
root.SetAttr("data-component-id", "number-input")
root.SetHTML(`<input id="stake" type="number" value="25"><span>old</span>`)
body.Call("appendChild", root.Value)
defer root.Call("remove")
input := root.Query("#stake")
input.SetValue("28")
input.Call("focus")
patchInnerHTML(root.Value, `<root data-component-id="number-input"><input id="stake" type="number" value="25"><span>new</span></root>`)
patched := root.Query("#stake")
if !patched.Equal(input.Value) {
t.Fatal("number input was replaced")
}
if got := patched.Val(); got != "28" {
t.Fatalf("number input value = %q, want 28", got)
}
if !js.Doc().Get("activeElement").Equal(patched.Value) {
t.Fatal("number input lost focus")
}
if got := root.Query("span").Text(); got != "new" {
t.Fatalf("patched text = %q, want new", got)
}
}
TestPatchPreservesFocusedTextInputIdentityAndCaret
Parameters
func TestPatchPreservesFocusedTextInputIdentityAndCaret(t *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("root")
root.SetAttr("data-component-id", "search-input")
root.SetHTML(`<input type="search" value="china"><span>old</span>`)
body.Call("appendChild", root.Value)
defer root.Call("remove")
input := root.Query("input")
input.SetValue("chinaa")
input.Call("focus")
input.Call("setSelectionRange", 2, 4, "forward")
patchInnerHTML(root.Value, `<root data-component-id="search-input"><input type="search" value="server"><span>new</span></root>`)
patched := root.Query("input")
if !patched.Equal(input.Value) {
t.Fatal("search input was replaced")
}
if !js.Doc().Get("activeElement").Equal(patched.Value) {
t.Fatal("search input lost focus")
}
if got := patched.Val(); got != "chinaa" {
t.Fatalf("live input value = %q, want chinaa", got)
}
if got := patched.Get("selectionStart").Int(); got != 2 {
t.Fatalf("selection start = %d, want 2", got)
}
if got := patched.Get("selectionEnd").Int(); got != 4 {
t.Fatalf("selection end = %d, want 4", got)
}
}
TestPatchDoesNotRestoreDeliberatelyBlurredInput
Parameters
func TestPatchDoesNotRestoreDeliberatelyBlurredInput(t *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("root")
root.SetAttr("data-component-id", "blurred-input")
root.SetHTML(`<input type="search"><button>Apply</button>`)
body.Call("appendChild", root.Value)
defer root.Call("remove")
input := root.Query("input")
input.Call("focus")
input.Call("blur")
patchInnerHTML(root.Value, `<root data-component-id="blurred-input"><input type="search"><button>Updated</button></root>`)
if js.Doc().Get("activeElement").Equal(input.Value) {
t.Fatal("patch restored focus after an explicit blur")
}
}
TestPatchPreservesUncontrolledFormProperties
Parameters
func TestPatchPreservesUncontrolledFormProperties(t *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("root")
root.SetAttr("data-component-id", "form-state")
root.SetHTML(`<textarea>initial</textarea><input type="checkbox"><input type="radio" name="choice"><select><option>A</option><option>B</option></select>`)
body.Call("appendChild", root.Value)
defer root.Call("remove")
textarea := root.Query("textarea")
checkbox := root.Query(`input[type="checkbox"]`)
radio := root.Query(`input[type="radio"]`)
selectEl := root.Query("select")
textarea.SetValue("operator text")
checkbox.Set("checked", true)
radio.Set("checked", true)
selectEl.Set("selectedIndex", 1)
patchInnerHTML(root.Value, `<root data-component-id="form-state"><textarea>server text</textarea><input type="checkbox"><input type="radio" name="choice"><select><option selected>A</option><option>B</option></select></root>`)
if !root.Query("textarea").Equal(textarea.Value) || root.Query("textarea").Val() != "operator text" {
t.Fatal("textarea identity or live value was not preserved")
}
if !root.Query(`input[type="checkbox"]`).Equal(checkbox.Value) || !checkbox.Checked() {
t.Fatal("checkbox identity or checked state was not preserved")
}
if !root.Query(`input[type="radio"]`).Equal(radio.Value) || !radio.Checked() {
t.Fatal("radio identity or checked state was not preserved")
}
if !root.Query("select").Equal(selectEl.Value) || selectEl.Get("selectedIndex").Int() != 1 {
t.Fatal("select identity or selected option was not preserved")
}
}
TestPatchPreservesAndReordersKeyedNodes
Parameters
func TestPatchPreservesAndReordersKeyedNodes(t *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("root")
root.SetAttr("data-component-id", "keyed-list")
root.SetHTML(`<ul><li data-key="a">A</li><li data-key="b">B</li></ul>`)
body.Call("appendChild", root.Value)
defer root.Call("remove")
a := root.Query(`[data-key="a"]`)
b := root.Query(`[data-key="b"]`)
patchInnerHTML(root.Value, `<root data-component-id="keyed-list"><ul><li data-key="b">B2</li><li data-key="a">A2</li><li data-key="c">C</li></ul></root>`)
rows := root.QueryAll("li")
if rows.Length() != 3 || !rows.Index(0).Equal(b.Value) || !rows.Index(1).Equal(a.Value) {
t.Fatalf("keyed rows lost identity or order: %s", root.HTML())
}
if rows.Index(0).Text() != "B2" || rows.Index(1).Text() != "A2" {
t.Fatalf("keyed row contents were not patched: %s", root.HTML())
}
}
TestInvalidPatchPlanLeavesDOMUntouched
Parameters
func TestInvalidPatchPlanLeavesDOMUntouched(t *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("root")
root.SetAttr("data-component-id", "atomic-plan")
root.SetHTML(`<ul><li data-key="a">A</li><li data-key="b">B</li></ul>`)
body.Call("appendChild", root.Value)
defer root.Call("remove")
before := root.HTML()
a := root.Query(`[data-key="a"]`)
var recovered any
func() {
defer func() { recovered = recover() }()
patchInnerHTML(root.Value, `<root data-component-id="atomic-plan"><ul><li data-key="a">changed</li><li data-key="a">duplicate</li></ul></root>`)
}()
if recovered == nil {
t.Fatal("duplicate identity did not reject the patch")
}
if got := root.HTML(); got != before {
t.Fatalf("invalid plan mutated DOM: got %s, want %s", got, before)
}
if !root.Query(`[data-key="a"]`).Equal(a.Value) {
t.Fatal("invalid plan replaced a node before failing")
}
}
TestKeyIdentityIsScopedToItsLoop
Parameters
func TestKeyIdentityIsScopedToItsLoop(t *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("root")
root.SetAttr("data-component-id", "loop-scopes")
root.SetHTML(`<section><i data-for="first" data-key="0">A</i><i data-for="second" data-key="0">B</i></section>`)
body.Call("appendChild", root.Value)
defer root.Call("remove")
patchInnerHTML(root.Value, `<root data-component-id="loop-scopes"><section><i data-for="first" data-key="0">A2</i><i data-for="second" data-key="0">B2</i></section></root>`)
rows := root.QueryAll("i")
if rows.Length() != 2 || rows.Index(0).Text() != "A2" || rows.Index(1).Text() != "B2" {
t.Fatalf("loop-scoped keys were treated as duplicates: %s", root.HTML())
}
}
TestPatchRespectsNestedDOMOwnership
Parameters
func TestPatchRespectsNestedDOMOwnership(t *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("root")
root.SetAttr("data-component-id", "shell")
root.SetHTML(`<span data-shell>old</span><root data-component-id="child"><div data-child>rendered</div></root><div data-router-outlet><root data-component-id="page"><div data-page>mounted</div></root></div>`)
body.Call("appendChild", root.Value)
defer root.Call("remove")
child := root.Query(`[data-component-id="child"]`)
page := root.Query(`[data-component-id="page"]`)
child.Query("[data-child]").SetHTML("imperative child")
page.Query("[data-page]").SetHTML("imperative page")
patchInnerHTML(root.Value, `<root data-component-id="shell"><span data-shell>new</span><root data-component-id="child"><div data-child>stale render</div></root><div data-router-outlet><p>empty render</p></div></root>`)
if root.Query("[data-shell]").Text() != "new" {
t.Fatal("shell-owned node was not patched")
}
if !root.Query(`[data-component-id="child"]`).Equal(child.Value) || child.Query("[data-child]").Text() != "imperative child" {
t.Fatal("parent patch crossed child component ownership")
}
if !root.Query(`[data-component-id="page"]`).Equal(page.Value) || page.Query("[data-page]").Text() != "imperative page" {
t.Fatal("parent patch crossed router outlet ownership")
}
}
TestPatchPreservesLiveAttributesUntilTemplateChangesThem
Parameters
func TestPatchPreservesLiveAttributesUntilTemplateChangesThem(t *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("root")
root.SetAttr("data-component-id", "live-attributes")
root.SetHTML(`<section class="panel" aria-expanded="false"><span>old</span></section>`)
recordRenderedTree(root.Value)
body.Call("appendChild", root.Value)
defer root.Call("remove")
panel := root.Query("section")
panel.AddClass("open")
panel.SetAttr("aria-expanded", "true")
patchInnerHTML(root.Value, `<root data-component-id="live-attributes"><section class="panel" aria-expanded="false"><span>new</span></section></root>`)
if !panel.HasClass("open") || panel.Attr("aria-expanded") != "true" {
t.Fatalf("unchanged template attributes erased live state: %s", root.HTML())
}
if panel.Query("span").Text() != "new" {
t.Fatal("attribute preservation blocked descendant patching")
}
patchInnerHTML(root.Value, `<root data-component-id="live-attributes"><section class="panel disabled" aria-expanded="mixed"><span>latest</span></section></root>`)
if panel.Attr("class") != "panel disabled" || panel.Attr("aria-expanded") != "mixed" {
t.Fatalf("changed template attributes did not take ownership: %s", root.HTML())
}
}
TestUpdateDOMRecoversAndAcceptsNextUpdate
Parameters
func TestUpdateDOMRecoversAndAcceptsNextUpdate(t *testing.T)
{
body := js.Doc().Get("body")
root := CreateElement("root")
root.SetAttr("data-component-id", "recover-update")
root.SetHTML("<span>old</span>")
body.Call("appendChild", root.Value)
defer root.Call("remove")
previousHook := TemplateHook
previousPanic := OnHandlerPanic
defer func() {
TemplateHook = previousHook
OnHandlerPanic = previousPanic
}()
recovered := 0
OnHandlerPanic = func(any, string) { recovered++ }
TemplateHook = func(string, string) { panic("template hook") }
UpdateDOM("recover-update", `<root data-component-id="recover-update"><span>first</span></root>`)
TemplateHook = nil
UpdateDOM("recover-update", `<root data-component-id="recover-update"><span>second</span></root>`)
if recovered != 1 {
t.Fatalf("recovered updates = %d, want 1", recovered)
}
if got := root.Query("span").Text(); got != "second" {
t.Fatalf("next update text = %q, want second", got)
}
}
TestDOMReporterPanicLogsOriginalFailure
Parameters
func TestDOMReporterPanicLogsOriginalFailure(t *testing.T)
{
previousPanic := OnHandlerPanic
previousWriter := log.Writer()
var output bytes.Buffer
OnHandlerPanic = func(any, string) { panic("reporter failure") }
log.SetOutput(&output)
defer func() {
OnHandlerPanic = previousPanic
log.SetOutput(previousWriter)
}()
func() {
defer recoverDOMUpdate("broken-component")
panic("original failure")
}()
logs := output.String()
if !strings.Contains(logs, "DOM panic reporter failed: reporter failure") {
t.Fatalf("missing reporter failure log: %s", logs)
}
if !strings.Contains(logs, "recovered DOM update panic for broken-component: original failure") {
t.Fatalf("missing original failure log: %s", logs)
}
}
Element
Element wraps a DOM element and provides typed helpers.
type Element struct
Methods
Query returns the first descendant matching the CSS selector.
Parameters
Returns
func (Element) Query(sel string) Element
{
return Element{e.Call("querySelector", sel)}
}
QueryAll returns all descendants matching the selector.
Parameters
Returns
func (Element) QueryAll(sel string) Element
{
return Element{e.Call("querySelectorAll", sel)}
}
ByClass returns all descendants with the given class name.
Parameters
Returns
func (Element) ByClass(name string) Element
{
return Element{e.Call("getElementsByClassName", name)}
}
ByTag returns all descendants with the given tag name.
Parameters
Returns
func (Element) ByTag(tag string) Element
{
return Element{e.Call("getElementsByTagName", tag)}
}
Text returns the element's text content.
Returns
func (Element) Text() string
{
if e.missing() {
return ""
}
return e.Get("textContent").String()
}
SetText sets the element's text content.
Parameters
func (Element) SetText(txt string)
{
if e.missing() {
return
}
e.Set("textContent", txt)
}
HTML returns the element's inner HTML.
Returns
func (Element) HTML() string
{
if e.missing() {
return ""
}
return e.Get("innerHTML").String()
}
SetHTML replaces the element's children with raw HTML.
Parameters
func (Element) SetHTML(html string)
{
if e.missing() {
return
}
e.Set("innerHTML", html)
}
AppendChild appends a child element.
Parameters
func (Element) AppendChild(child Element)
{
if e.missing() {
return
}
e.Call("appendChild", child.Value)
}
Attr retrieves the value of an attribute or "" if unset.
Parameters
Returns
func (Element) Attr(name string) string
{
if e.missing() {
return ""
}
v := e.Call("getAttribute", name)
if v.Truthy() {
return v.String()
}
return ""
}
SetAttr sets the value of an attribute on the element.
Parameters
func (Element) SetAttr(name, value string)
{
if e.missing() {
return
}
e.Call("setAttribute", name, value)
}
RemoveAttr drops an attribute from the element, the counterpart of SetAttr (needed for boolean attributes such as disabled, where an empty value still reads as set).
Parameters
func (Element) RemoveAttr(name string)
{
if e.missing() {
return
}
e.Call("removeAttribute", name)
}
Matches reports whether the element itself satisfies the selector, the non-walking counterpart of Closest.
Parameters
Returns
func (Element) Matches(sel string) bool
{
if e.missing() {
return false
}
return e.Call("matches", sel).Bool()
}
SetStyle sets an inline style property on the element.
Parameters
func (Element) SetStyle(prop, value string)
{
if e.missing() {
return
}
e.Get("style").Call("setProperty", prop, value)
}
AddClass adds a class to the element.
Parameters
func (Element) AddClass(name string)
{
if e.missing() {
return
}
e.Get("classList").Call("add", name)
}
RemoveClass removes a class from the element.
Parameters
func (Element) RemoveClass(name string)
{
if e.missing() {
return
}
e.Get("classList").Call("remove", name)
}
HasClass reports whether the element has the given class.
Parameters
Returns
func (Element) HasClass(name string) bool
{
if e.missing() {
return false
}
return e.Get("classList").Call("contains", name).Bool()
}
ToggleClass toggles the presence of a class on the element.
Parameters
func (Element) ToggleClass(name string)
{
if e.missing() {
return
}
e.Get("classList").Call("toggle", name)
}
Length returns the number of children when the element represents a collection.
Returns
func (Element) Length() int
{ return e.Get("length").Int() }
Index retrieves the element at the given position when representing a collection.
Parameters
Returns
func (Element) Index(i int) Element
{ return Element{e.Value.Index(i)} }
Val returns the element's value property (inputs, selects, textareas). Named Val because the embedded js.Value field occupies Value.
Returns
func (Element) Val() string
{
if e.missing() {
return ""
}
return e.Get("value").String()
}
SetValue sets the element's value property.
Parameters
func (Element) SetValue(v string)
{
if e.missing() {
return
}
e.Set("value", v)
}
Checked reports whether a checkbox or radio input is checked.
Returns
func (Element) Checked() bool
{
if e.missing() {
return false
}
return e.Get("checked").Bool()
}
Data reads a data-* attribute by its dataset key (camelCase: data-item-id becomes Data("itemId")).
Parameters
Returns
func (Element) Data(key string) string
{
if e.missing() {
return ""
}
v := e.Get("dataset").Get(key)
if !v.Truthy() {
return ""
}
return v.String()
}
Closest returns the nearest ancestor (or the element itself) matching the selector; check IsNull on the result for no match.
Parameters
Returns
func (Element) Closest(sel string) Element
{
if e.missing() {
return e
}
return Element{e.Call("closest", sel)}
}
missing reports whether the element does not exist. Query and friends return a null element instead of panicking; mutators are no-ops on it and readers return zero values, so an async callback that outlives its page (an SPA navigation while a fetch is in flight) degrades gracefully instead of killing the wasm process.
Returns
func (Element) missing() bool
{ return e.IsNull() || e.IsUndefined() }
On attaches a listener for event to the element and returns a stop function.
Parameters
Returns
func (Element) On(event string, handler func(Event)) func()
{
fn := js.SafeFuncOf(func(_ js.Value, args []js.Value) any {
var evt js.Value
if len(args) > 0 {
evt = args[0]
}
handler(Event{evt})
return nil
})
e.Call("addEventListener", event, fn)
return func() {
e.Call("removeEventListener", event, fn)
fn.Release()
}
}
OnClick attaches a click handler to the element.
Parameters
Returns
func (Element) OnClick(handler func(Event)) func()
{
return e.On("click", handler)
}
RegisterHandler
RegisterHandler registers a Go function with custom arguments in the handler registry.
If a handler with the same name already exists, the old wrapper is released.
Parameters
func RegisterHandler(name string, fn func(this js.Value, args []js.Value) any)
{
handlerMu.Lock()
defer handlerMu.Unlock()
if old, ok := handlerRegistry[name]; ok {
old.Release()
}
handlerRegistry[name] = js.SafeFuncOf(fn)
}
RegisterComponentHandler
RegisterComponentHandler registers a handler owned by one component instance.
Parameters
func RegisterComponentHandler(componentID, name string, fn func(this js.Value, args []js.Value) any)
{
handlerMu.Lock()
defer handlerMu.Unlock()
if componentHandlerRegistry[componentID] == nil {
componentHandlerRegistry[componentID] = make(map[string]js.Func)
}
if old, ok := componentHandlerRegistry[componentID][name]; ok {
old.Release()
}
componentHandlerRegistry[componentID][name] = js.SafeFuncOf(fn)
}
RegisterHandlerFunc
RegisterHandlerFunc registers a no-argument Go function in the handler registry.
Parameters
func RegisterHandlerFunc(name string, fn func())
{
RegisterHandler(name, func(_ js.Value, _ []js.Value) any {
fn()
return nil
})
}
RegisterComponentHandlerFunc
RegisterComponentHandlerFunc registers a no-argument component handler.
Parameters
func RegisterComponentHandlerFunc(componentID, name string, fn func())
{
RegisterComponentHandler(componentID, name, func(_ js.Value, _ []js.Value) any {
fn()
return nil
})
}
RegisterHandlerEvent
RegisterHandlerEvent registers a Go function that receives the first argument as an event object.
Parameters
func RegisterHandlerEvent(name string, fn func(js.Value))
{
RegisterHandler(name, func(_ js.Value, args []js.Value) any {
var evt js.Value
if len(args) > 0 {
evt = args[0]
}
fn(evt)
return nil
})
}
RegisterHandlerElem
RegisterHandlerElem registers a handler that receives the element carrying
the data-on-* attribute (resolved by event delegation, so it works for
markup injected at runtime) together with the event. This is the idiomatic
way to handle clicks on list rows: render rows with data-on-click=“name”
and read the row’s data-* attributes from el.
Parameters
func RegisterHandlerElem(name string, fn func(el Element, evt Event))
{
RegisterHandler(name, func(_ js.Value, args []js.Value) any {
var evt, el js.Value
if len(args) > 0 {
evt = args[0]
}
if len(args) > 1 {
el = args[1]
} else if evt.Truthy() {
el = evt.Get("target")
}
fn(Element{el}, Event{evt})
return nil
})
}
GetHandler
GetHandler retrieves a registered handler by name.
Parameters
Returns
func GetHandler(name string) js.Func
{
handlerMu.RLock()
defer handlerMu.RUnlock()
if v, ok := handlerRegistry[name]; ok {
return v
}
return js.Func{}
}
GetComponentHandler
GetComponentHandler resolves a component handler before the global fallback.
Parameters
Returns
func GetComponentHandler(componentID, name string) js.Func
{
handlerMu.RLock()
defer handlerMu.RUnlock()
if handlers := componentHandlerRegistry[componentID]; handlers != nil {
if v, ok := handlers[name]; ok {
return v
}
}
return handlerRegistry[name]
}
ReleaseComponentHandlers
ReleaseComponentHandlers releases every handler owned by a component.
Parameters
func ReleaseComponentHandlers(componentID string)
{
handlerMu.Lock()
handlers := componentHandlerRegistry[componentID]
delete(componentHandlerRegistry, componentID)
handlerMu.Unlock()
for _, handler := range handlers {
handler.Release()
}
}
delegatedHandler
type delegatedHandler struct
Fields
| Name | Type | Description |
|---|---|---|
| event | string | |
| capture | bool | |
| fn | js.Func | |
| stop | func() |
DelegateEvents
DelegateEvents attaches delegated event listeners on the component root
element. Bubbling events bubble up to root where data-on-* attributes
are resolved to registered handlers.
Delegating twice for the same component (a remount, a root replaced by a
re-render of the surrounding markup) replaces the previous set: keeping it
would fire every handler twice and leak one js.Func per event per remount.
Parameters
func DelegateEvents(componentID string, root js.Value)
{
RemoveDelegatedEvents(componentID, root)
var handlers []delegatedHandler
events := []string{"click", "submit", "input", "change", "keydown", "keyup", "focus", "blur"}
for _, evtName := range events {
for _, capture := range []bool{false, true} {
if (evtName == "focus" || evtName == "blur") && !capture {
continue
}
handler := newDelegatedHandler(componentID, root, evtName, capture)
handlers = append(handlers, handler)
root.Call("addEventListener", evtName, handler.fn, capture)
}
}
delegateMu.Lock()
delegates[componentID] = handlers
delegateMu.Unlock()
}
newDelegatedHandler
Parameters
Returns
func newDelegatedHandler(componentID string, root js.Value, event string, capture bool) delegatedHandler
{
var timerMu sync.Mutex
timers := make(map[string]*time.Timer)
throttled := make(map[string]time.Time)
fn := js.SafeFuncOf(func(_ js.Value, args []js.Value) any {
if len(args) == 0 {
return nil
}
evt := args[0]
target := evt.Get("target")
for target.Truthy() {
handlerName := target.Call("getAttribute", "data-on-"+event)
if handlerName.Truthy() {
modifiers := eventModifiers(target, event)
_, wantsCapture := modifiers["capture"]
nonBubbling := event == "focus" || event == "blur"
if (nonBubbling || wantsCapture == capture) && eventAllowed(evt, target, modifiers) {
h := GetComponentHandler(componentID, handlerName.String())
if h.Truthy() {
key := eventBindingKey(target, event, handlerName.String())
if !claimEventBinding(evt, key) {
if target.Equal(root) {
break
}
target = target.Get("parentElement")
continue
}
if _, ok := modifiers["prevent"]; ok {
if _, passive := modifiers["passive"]; !passive {
evt.Call("preventDefault")
}
}
if _, ok := modifiers["stop"]; ok {
evt.Call("stopPropagation")
}
if _, ok := modifiers["once"]; ok {
target.Call("removeAttribute", "data-on-"+event)
target.Call("removeAttribute", "data-on-"+event+"-modifiers")
}
invoke := func() {
defer func() {
if r := recover(); r != nil && OnHandlerPanic != nil {
OnHandlerPanic(r, handlerName.String())
}
}()
h.Invoke(evt, target)
}
if delay, ok := modifierDelay(modifiers, "debounce"); ok {
timerMu.Lock()
if timer := timers[key]; timer != nil {
timer.Stop()
}
var scheduled *time.Timer
scheduled = time.AfterFunc(delay, func() {
invoke()
timerMu.Lock()
if timers[key] == scheduled {
delete(timers, key)
}
timerMu.Unlock()
})
timers[key] = scheduled
timerMu.Unlock()
return nil
}
if delay, ok := modifierDelay(modifiers, "throttle"); ok {
timerMu.Lock()
last := throttled[key]
if time.Since(last) < delay {
timerMu.Unlock()
return nil
}
throttled[key] = time.Now()
timerMu.Unlock()
}
invoke()
return nil
}
}
}
if target.Equal(root) {
break
}
target = target.Get("parentElement")
}
return nil
})
stop := func() {
timerMu.Lock()
for _, timer := range timers {
timer.Stop()
}
clear(timers)
clear(throttled)
timerMu.Unlock()
}
return delegatedHandler{event: event, capture: capture, fn: fn, stop: stop}
}
claimEventBinding
Parameters
Returns
func claimEventBinding(evt js.Value, key string) bool
{
const property = "__rfwDelegatedClaims"
claims := evt.Get(property)
if claims.Type() != js.TypeObject {
claims = js.NewDict().Value
evt.Set(property, claims)
}
if claims.Get(key).Truthy() {
return false
}
claims.Set(key, true)
return true
}
eventModifiers
Parameters
Returns
func eventModifiers(target js.Value, event string) map[string]struct{}
{
raw := target.Call("getAttribute", "data-on-"+event+"-modifiers")
modifiers := make(map[string]struct{})
if !raw.Truthy() {
return modifiers
}
for _, modifier := range strings.Split(raw.String(), ",") {
modifier = strings.ToLower(strings.TrimSpace(modifier))
if modifier != "" {
modifiers[modifier] = struct{}{}
}
}
return modifiers
}
eventAllowed
func eventAllowed(evt, target js.Value, modifiers map[string]struct{}) bool
{
if _, ok := modifiers["self"]; ok && !evt.Get("target").Equal(target) {
return false
}
keys := map[string]string{
"enter": "Enter", "escape": "Escape", "tab": "Tab", "space": " ",
"up": "ArrowUp", "down": "ArrowDown", "left": "ArrowLeft", "right": "ArrowRight",
}
for modifier, key := range keys {
if _, ok := modifiers[modifier]; ok && evt.Get("key").String() != key {
return false
}
}
system := map[string]string{"ctrl": "ctrlKey", "shift": "shiftKey", "alt": "altKey", "meta": "metaKey"}
for modifier, property := range system {
if _, ok := modifiers[modifier]; ok && !evt.Get(property).Bool() {
return false
}
}
if _, exact := modifiers["exact"]; exact {
for modifier, property := range system {
_, required := modifiers[modifier]
if evt.Get(property).Bool() != required {
return false
}
}
}
return true
}
modifierDelay
Parameters
Returns
func modifierDelay(modifiers map[string]struct{}, name string) (time.Duration, bool)
{
if _, ok := modifiers[name]; !ok {
return 0, false
}
delay := 300
for modifier := range modifiers {
if ms, err := strconv.Atoi(modifier); err == nil && ms >= 0 {
delay = ms
break
}
}
return time.Duration(delay) * time.Millisecond, true
}
eventBindingKey
Parameters
Returns
func eventBindingKey(target js.Value, event, handler string) string
{
const property = "__rfwEventBinding"
id := target.Get(property)
if !id.Truthy() {
value := strconv.FormatUint(eventBindingSeq.Add(1), 10)
target.Set(property, value)
id = target.Get(property)
}
return id.String() + ":" + event + ":" + handler
}
RemoveDelegatedEvents
RemoveDelegatedEvents removes all delegated event listeners for the given component.
Parameters
func RemoveDelegatedEvents(componentID string, root js.Value)
{
delegateMu.Lock()
handlers, ok := delegates[componentID]
if ok {
delete(delegates, componentID)
}
delegateMu.Unlock()
if !ok {
return
}
// A root that is already gone (its subtree was replaced) cannot have its
// listeners detached, but the callbacks still have to be released.
live := root.Truthy()
for _, handler := range handlers {
if live {
root.Call("removeEventListener", handler.event, handler.fn.Value, handler.capture)
}
handler.stop()
handler.fn.Release()
}
}
LifecycleHook
LifecycleHook observes a component root after mount, update, and unmount.
type LifecycleHook struct
Fields
| Name | Type | Description |
|---|---|---|
| Mounted | func(Element) func() | |
| Updated | func(Element) | |
| Unmounted | func(Element) |
lifecycleRecord
type lifecycleRecord struct
Methods
Parameters
func (*lifecycleRecord) setCleanup(cleanup func())
{
record.mu.Lock()
if record.stopped {
record.mu.Unlock()
if cleanup != nil {
cleanup()
}
return
}
record.cleanup = cleanup
record.mu.Unlock()
}
Parameters
Returns
func (*lifecycleRecord) takeCleanup(stop bool) func()
{
record.mu.Lock()
cleanup := record.cleanup
record.cleanup = nil
if stop {
record.stopped = true
}
record.mu.Unlock()
return cleanup
}
Fields
| Name | Type | Description |
|---|---|---|
| id | uint64 | |
| hook | LifecycleHook | |
| mu | sync.Mutex | |
| cleanup | func() | |
| stopped | bool |
Uses
componentLifecycle
type componentLifecycle struct
Fields
| Name | Type | Description |
|---|---|---|
| mounted | bool | |
| hooks | []*lifecycleRecord |
RegisterLifecycleHook
RegisterLifecycleHook registers a hook and returns a cancellation function.
Parameters
Returns
func RegisterLifecycleHook(componentID string, hook LifecycleHook) func()
{
record := &lifecycleRecord{id: lifecycleHooks.sequence.Add(1), hook: hook}
lifecycleHooks.Lock()
component := lifecycleHooks.components[componentID]
if component == nil {
component = &componentLifecycle{}
lifecycleHooks.components[componentID] = component
}
component.hooks = append(component.hooks, record)
mounted := component.mounted
lifecycleHooks.Unlock()
if mounted && hook.Mounted != nil {
record.setCleanup(runMountedHook(componentID, hook.Mounted, ownComponentRoot(componentID)))
}
var once sync.Once
return func() {
once.Do(func() {
lifecycleHooks.Lock()
component := lifecycleHooks.components[componentID]
if component != nil {
for index, candidate := range component.hooks {
if candidate.id == record.id {
component.hooks = append(component.hooks[:index], component.hooks[index+1:]...)
break
}
}
if len(component.hooks) == 0 {
delete(lifecycleHooks.components, componentID)
}
}
cleanup := record.takeCleanup(true)
lifecycleHooks.Unlock()
if cleanup != nil {
cleanup()
}
})
}
}
Uses
MountLifecycleHooks
MountLifecycleHooks activates every hook registered for a component.
Parameters
func MountLifecycleHooks(componentID string)
{
lifecycleHooks.Lock()
component := lifecycleHooks.components[componentID]
if component == nil || component.mounted {
lifecycleHooks.Unlock()
return
}
component.mounted = true
hooks := append([]*lifecycleRecord(nil), component.hooks...)
lifecycleHooks.Unlock()
root := ownComponentRoot(componentID)
for _, record := range hooks {
if record.hook.Mounted != nil {
record.setCleanup(runMountedHook(componentID, record.hook.Mounted, root))
}
}
}
UpdateLifecycleHooks
UpdateLifecycleHooks notifies mounted component hooks after a DOM patch.
Parameters
func UpdateLifecycleHooks(componentID string)
{
lifecycleHooks.Lock()
component := lifecycleHooks.components[componentID]
if component == nil || !component.mounted {
lifecycleHooks.Unlock()
return
}
hooks := append([]*lifecycleRecord(nil), component.hooks...)
lifecycleHooks.Unlock()
root := ownComponentRoot(componentID)
for _, record := range hooks {
if record.hook.Updated != nil {
runLifecycleHook(componentID, "updated", func() {
record.hook.Updated(root)
})
}
}
}
UnmountLifecycleHooks
UnmountLifecycleHooks runs hook cleanup while the component root still exists.
Parameters
func UnmountLifecycleHooks(componentID string)
{
lifecycleHooks.Lock()
component := lifecycleHooks.components[componentID]
if component == nil || !component.mounted {
lifecycleHooks.Unlock()
return
}
component.mounted = false
hooks := append([]*lifecycleRecord(nil), component.hooks...)
lifecycleHooks.Unlock()
root := ownComponentRoot(componentID)
for index := len(hooks) - 1; index >= 0; index-- {
record := hooks[index]
if cleanup := record.takeCleanup(false); cleanup != nil {
runLifecycleHook(componentID, "cleanup", cleanup)
}
if record.hook.Unmounted != nil {
runLifecycleHook(componentID, "unmounted", func() {
record.hook.Unmounted(root)
})
}
}
}
runMountedHook
Parameters
Returns
func runMountedHook(componentID string, hook func(Element) func(), root Element) (cleanup func())
{
runLifecycleHook(componentID, "mounted", func() {
cleanup = hook(root)
})
return cleanup
}
Uses
runLifecycleHook
Parameters
func runLifecycleHook(componentID, phase string, fn func())
{
defer func() {
if recovered := recover(); recovered != nil && OnHandlerPanic != nil {
OnHandlerPanic(recovered, "DOM "+phase+": "+componentID)
}
}()
fn()
}
ownComponentRoot
Parameters
Returns
func ownComponentRoot(componentID string) Element
{
root := ComponentRoot(componentID)
if root.IsNull() || root.IsUndefined() || root.Attr("data-component-id") != componentID {
return Element{}
}
return root
}
Uses
binding
binding represents a precompiled event binding.
type binding struct
Fields
| Name | Type | Description |
|---|---|---|
| Path | []int | |
| Event | string | |
| Handler | string | |
| Modifiers | []string |
RegisterBindings
RegisterBindings generates and associates bindings for a component instance.
Parameters
func RegisterBindings(id, name, template string)
{
if bs, ok := precompiledByName[name]; ok {
compiledBindings[id] = bs
return
}
bs, err := parseTemplate(template)
if err != nil {
return
}
precompiledByName[name] = bs
compiledBindings[id] = bs
}
OverrideBindings
OverrideBindings replaces the cached bindings for a component name.
Parameters
func OverrideBindings(name, template string)
{
bs, err := parseTemplate(template)
if err != nil {
return
}
precompiledByName[name] = bs
}
parseTemplate
Parameters
Returns
func parseTemplate(tpl string) ([]binding, error)
{
processed := replaceEventHandlers(tpl)
node, err := html.Parse(strings.NewReader(processed))
if err != nil {
return nil, err
}
return collectBindings(node, nil), nil
}
collectBindings
func collectBindings(n *html.Node, path []int) []binding
{
var res []binding
if n.Type == html.ElementNode {
attrs := map[string]string{}
for _, a := range n.Attr {
attrs[a.Key] = a.Val
}
for k, v := range attrs {
if strings.HasPrefix(k, "data-on-") && !strings.HasSuffix(k, "-modifiers") {
event := strings.TrimPrefix(k, "data-on-")
mods := []string{}
if m, ok := attrs[fmt.Sprintf("data-on-%s-modifiers", event)]; ok && m != "" {
for _, s := range strings.Split(m, ",") {
s = strings.TrimSpace(s)
if s != "" {
mods = append(mods, s)
}
}
}
res = append(res, binding{Path: append([]int(nil), path...), Event: event, Handler: v, Modifiers: mods})
}
}
}
child := n.FirstChild
idx := 0
for child != nil {
res = append(res, collectBindings(child, append(path, idx))...)
child = child.NextSibling
idx++
}
return res
}
replaceEventHandlers
Parameters
Returns
func replaceEventHandlers(template string) string
{
return eventRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := eventRegex.FindStringSubmatch(match)
if len(parts) != 5 {
return match
}
fullEvent := parts[2]
handler := parts[3]
suffix := parts[4]
eventParts := strings.Split(fullEvent, ".")
event := eventParts[0]
modifiers := []string{}
if len(eventParts) > 1 {
modifiers = eventParts[1:]
}
attr := fmt.Sprintf("data-on-%s=\"%s\"", event, handler)
if len(modifiers) > 0 {
attr += fmt.Sprintf(" data-on-%s-modifiers=\"%s\"", event, strings.Join(modifiers, ","))
}
return attr + suffix
})
}
TestStyleInline
Parameters
func TestStyleInline(t *testing.T)
{
got := StyleInline(map[string]string{"color": "red", "display": "block"})
if !strings.Contains(got, "color:red") || !strings.Contains(got, "display:block") {
t.Fatalf("StyleInline() = %q", got)
}
}
TestNullElementIsInert
A null element (missing query result) must be inert: mutators no-op and
readers return zero values instead of panicking.
Parameters
func TestNullElementIsInert(t *testing.T)
{
el := Doc().Query("#does-not-exist")
el.SetHTML("<b>x</b>")
el.SetText("x")
el.SetAttr("a", "b")
el.SetStyle("color", "red")
el.SetValue("v")
el.AddClass("c")
el.RemoveClass("c")
el.ToggleClass("c")
if el.Text() != "" || el.HTML() != "" || el.Val() != "" || el.Attr("a") != "" ||
el.Checked() || el.HasClass("c") || el.Data("x") != "" {
t.Fatalf("null element readers must return zero values")
}
}
ExpandEvents
ExpandEvents rewrites @on:event:handler directives into the data-on-*
attributes event delegation resolves. Templates go through it
automatically; call it on markup built at runtime so dynamic rows can use
the same syntax as .rtml files:
rows += <tr @on:click:openRow data-id=" + id + ">...</tr>
el.SetHTML(dom.ExpandEvents(rows))
Parameters
Returns
func ExpandEvents(markup string) string
{
return reExpandEvent.ReplaceAllStringFunc(markup, func(match string) string {
parts := reExpandEvent.FindStringSubmatch(match)
if len(parts) != 5 {
return match
}
fullEvent := parts[2]
handler := parts[3]
suffix := parts[4]
eventParts := strings.Split(fullEvent, ".")
event := eventParts[0]
attr := fmt.Sprintf("data-on-%s=%q", event, handler)
if len(eventParts) > 1 {
attr += fmt.Sprintf(" data-on-%s-modifiers=%q", event, strings.Join(eventParts[1:], ","))
}
return attr + suffix
})
}
patchPlan
patchPlan separates reconciliation decisions from DOM mutation. Planning is
read-only: invalid identities or ownership conflicts are reported before the
first operation is committed.
type patchPlan struct
Methods
func (*patchPlan) commit()
{
for _, operation := range plan.ops {
operation()
}
}
func (*patchPlan) planNode(existing, replacement js.Value) error
{
if existing.Get("nodeType").Int() != replacement.Get("nodeType").Int() ||
existing.Get("nodeName").String() != replacement.Get("nodeName").String() {
return fmt.Errorf("rfw DOM patch: incompatible nodes %s and %s", existing.Get("nodeName").String(), replacement.Get("nodeName").String())
}
nodeType := replacement.Get("nodeType").Int()
if nodeType == 3 || nodeType == 8 { // text or comment
oldValue := existing.Get("nodeValue").String()
newValue := replacement.Get("nodeValue").String()
if oldValue != newValue {
plan.ops = append(plan.ops, func() { existing.Set("nodeValue", newValue) })
}
return nil
}
if nodeType != 1 {
return plan.planChildren(existing, replacement)
}
// A parent owns the presence of a child component, never its internals. The
// child schedules and patches its own root. Router outlets follow the same
// rule for their contents while allowing the shell to own outlet attributes.
componentID := attribute(existing, "data-component-id")
if componentID != "" && componentID != plan.ownerID {
if componentID != attribute(replacement, "data-component-id") {
return fmt.Errorf("rfw DOM patch: component ownership changed from %q", componentID)
}
return nil
}
formState := captureLiveFormState(existing, replacement)
plan.planAttributes(existing, replacement)
if isRouterOutlet(existing) && isRouterOutlet(replacement) {
return nil
}
if attribute(existing, "data-condition") != "" &&
attribute(existing, "data-condition-branch") != attribute(replacement, "data-condition-branch") {
return plan.planReplaceChildren(existing, replacement)
}
if err := plan.planChildren(existing, replacement); err != nil {
return err
}
if formState != nil {
plan.ops = append(plan.ops, func() { restoreLiveFormState(existing, *formState) })
}
return nil
}
func (*patchPlan) planReplaceChildren(parent, replacementParent js.Value) error
{
replacements := significantChildren(replacementParent)
if _, err := indexIdentities(replacements); err != nil {
return err
}
oldChildren := significantChildren(parent)
plan.ops = append(plan.ops, func() {
for _, child := range oldChildren {
child.Call("remove")
}
for _, replacement := range replacements {
clone := replacement.Call("cloneNode", true)
parent.Call("appendChild", clone)
recordRenderedTreeFromSource(clone, replacement)
}
})
return nil
}
func (*patchPlan) planAttributes(existing, replacement js.Value)
{
previous := renderedAttributes(existing)
next := attributeSnapshot(replacement)
names := make(map[string]struct{}, len(previous)+len(next))
for name := range previous {
names[name] = struct{}{}
}
for name := range next {
names[name] = struct{}{}
}
for name := range names {
previousValue, previouslyRendered := previous[name]
nextValue, renderedNext := next[name]
if !frameworkOwnedAttribute(name) && previouslyRendered == renderedNext && previousValue == nextValue {
continue
}
if !renderedNext {
if existing.Call("hasAttribute", name).Bool() {
attrName := name
plan.ops = append(plan.ops, func() { existing.Call("removeAttribute", attrName) })
}
continue
}
if !existing.Call("hasAttribute", name).Bool() || existing.Call("getAttribute", name).String() != nextValue {
attrName, attrValue := name, nextValue
plan.ops = append(plan.ops, func() { existing.Call("setAttribute", attrName, attrValue) })
}
}
plan.ops = append(plan.ops, func() { setRenderedAttributes(existing, next) })
}
func (*patchPlan) planChildren(parent, replacementParent js.Value) error
{
oldChildren := significantChildren(parent)
newChildren := significantChildren(replacementParent)
oldByIdentity, err := indexIdentities(oldChildren)
if err != nil {
return err
}
if _, err := indexIdentities(newChildren); err != nil {
return err
}
consumed := make([]bool, len(oldChildren))
placements := make([]childPlacement, 0, len(newChildren))
nextUnkeyed := 0
for _, replacement := range newChildren {
identity := nodeIdentity(replacement)
if identity != "" {
if index, ok := oldByIdentity[identity]; ok {
existing := oldChildren[index]
if !samePatchType(existing, replacement) {
return fmt.Errorf("rfw DOM patch: identity %q changed node type", identity)
}
consumed[index] = true
if err := plan.planNode(existing, replacement); err != nil {
return err
}
placements = append(placements, childPlacement{existing: existing})
} else {
placements = append(placements, childPlacement{source: replacement})
}
continue
}
for nextUnkeyed < len(oldChildren) && (consumed[nextUnkeyed] || nodeIdentity(oldChildren[nextUnkeyed]) != "") {
nextUnkeyed++
}
if nextUnkeyed < len(oldChildren) && samePatchType(oldChildren[nextUnkeyed], replacement) {
existing := oldChildren[nextUnkeyed]
consumed[nextUnkeyed] = true
nextUnkeyed++
if err := plan.planNode(existing, replacement); err != nil {
return err
}
placements = append(placements, childPlacement{existing: existing})
continue
}
placements = append(placements, childPlacement{source: replacement})
}
plan.ops = append(plan.ops, func() {
cursor := firstSignificantChild(parent)
for index := range placements {
placement := &placements[index]
node := placement.existing
if !node.Truthy() {
node = placement.source.Call("cloneNode", true)
recordRenderedTreeFromSource(node, placement.source)
}
if !cursor.Truthy() {
parent.Call("appendChild", node)
} else if node.Equal(cursor) {
cursor = nextSignificantSibling(cursor)
} else {
parent.Call("insertBefore", node, cursor)
}
}
for index, child := range oldChildren {
if consumed[index] {
continue
}
currentParent := child.Get("parentNode")
if currentParent.Truthy() && currentParent.Equal(parent) {
child.Call("remove")
}
}
})
return nil
}
Fields
| Name | Type | Description |
|---|---|---|
| ownerID | string | |
| ops | []func() |
liveFormState
type liveFormState struct
Fields
| Name | Type | Description |
|---|---|---|
| value | string | |
| checked | bool | |
| selectedIndex | int | |
| selectionAt | int | |
| selectionEnd | int | |
| selectionDir | string | |
| hasValue | bool | |
| hasChecked | bool | |
| hasSelected | bool | |
| hasCaret | bool |
activeFormState
type activeFormState struct
Methods
func (activeFormState) restore()
{
if snapshot.state == nil || !snapshot.element.Truthy() || !snapshot.element.Get("isConnected").Bool() {
return
}
active := js.Global().Get("document").Get("activeElement")
if !active.Truthy() || !active.Equal(snapshot.element) {
return
}
restoreLiveFormState(snapshot.element, *snapshot.state)
}
Fields
| Name | Type | Description |
|---|---|---|
| element | js.Value | |
| state | *liveFormState |
captureActiveFormState
Parameters
Returns
func captureActiveFormState(root js.Value) activeFormState
{
active := js.Global().Get("document").Get("activeElement")
if !active.Truthy() || !root.Call("contains", active).Bool() {
return activeFormState{}
}
return activeFormState{element: active, state: captureLiveFormState(active, active)}
}
patchInnerHTML
Parameters
func patchInnerHTML(element js.Value, html string)
{
template := CreateElement("template")
template.Set("innerHTML", html)
newContent := template.Get("content")
ownerID := attribute(element, "data-component-id")
plan := &patchPlan{ownerID: ownerID}
firstChild := newContent.Get("firstChild")
if firstChild.Truthy() && firstChild.Get("nodeName").String() == "ROOT" &&
ownerID != "" && attribute(firstChild, "data-component-id") == ownerID {
if err := plan.planNode(element, firstChild); err != nil {
panic(err)
}
} else if err := plan.planChildren(element, newContent); err != nil {
panic(err)
}
plan.commit()
}
indexIdentities
Parameters
Returns
func indexIdentities(children []js.Value) (map[string]int, error)
{
indexed := make(map[string]int)
for index, child := range children {
identity := nodeIdentity(child)
if identity == "" {
continue
}
if _, exists := indexed[identity]; exists {
return nil, fmt.Errorf("rfw DOM patch: duplicate sibling identity %q", identity)
}
indexed[identity] = index
}
return indexed, nil
}
nodeIdentity
Parameters
Returns
func nodeIdentity(node js.Value) string
{
if node.Get("nodeType").Int() != 1 {
return ""
}
if key := attribute(node, "data-key"); key != "" {
return "key:" + attribute(node, "data-for") + ":" + key
}
for _, candidate := range []struct {
attribute string
prefix string
}{
{"data-component-id", "component:"},
{"data-condition", "condition:"},
{"data-for-anchor", "for-anchor:"},
{"data-portal-id", "portal:"},
} {
if value := attribute(node, candidate.attribute); value != "" {
return candidate.prefix + value
}
}
if node.Call("hasAttribute", "data-router-outlet").Bool() {
return "router-outlet"
}
if node.Call("hasAttribute", "data-portal-anchor").Bool() {
return "portal-anchor"
}
if node.Call("hasAttribute", "data-keepalive-host").Bool() {
return "keepalive-host"
}
return ""
}
samePatchType
func samePatchType(existing, replacement js.Value) bool
{
return existing.Get("nodeType").Int() == replacement.Get("nodeType").Int() &&
existing.Get("nodeName").String() == replacement.Get("nodeName").String()
}
significantChildren
func significantChildren(parent js.Value) []js.Value
{
children := parent.Get("childNodes")
out := make([]js.Value, 0, children.Length())
for i := 0; i < children.Length(); i++ {
child := children.Index(i)
if child.Get("nodeType").Int() == 3 && strings.TrimSpace(child.Get("nodeValue").String()) == "" {
continue
}
out = append(out, child)
}
return out
}
nextSignificantNode
func nextSignificantNode(node js.Value) js.Value
{
for node.Truthy() && node.Get("nodeType").Int() == 3 && strings.TrimSpace(node.Get("nodeValue").String()) == "" {
node = node.Get("nextSibling")
}
return node
}
isRouterOutlet
Parameters
Returns
func isRouterOutlet(node js.Value) bool
{
return node.Get("nodeType").Int() == 1 && node.Call("hasAttribute", "data-router-outlet").Bool()
}
attribute
Parameters
Returns
func attribute(node js.Value, name string) string
{
if node.Get("nodeType").Int() != 1 || !node.Call("hasAttribute", name).Bool() {
return ""
}
return node.Call("getAttribute", name).String()
}
attributeSnapshot
Parameters
Returns
func attributeSnapshot(node js.Value) map[string]string
{
attributes := make(map[string]string)
if node.Get("nodeType").Int() != 1 {
return attributes
}
names := node.Call("getAttributeNames")
for i := 0; i < names.Length(); i++ {
name := names.Index(i).String()
attributes[name] = node.Call("getAttribute", name).String()
}
return attributes
}
renderedAttributes
Parameters
Returns
func renderedAttributes(node js.Value) map[string]string
{
rendered := node.Get(renderedAttrsProperty)
if rendered.Type() != js.TypeObject {
return attributeSnapshot(node)
}
attributes := make(map[string]string)
keys := js.Object().Call("keys", rendered)
for i := 0; i < keys.Length(); i++ {
name := keys.Index(i).String()
attributes[name] = rendered.Get(name).String()
}
return attributes
}
setRenderedAttributes
Parameters
func setRenderedAttributes(node js.Value, attributes map[string]string)
{
rendered := js.NewDict()
for name, value := range attributes {
rendered.Set(name, value)
}
node.Set(renderedAttrsProperty, rendered.Value)
}
recordRenderedTree
Parameters
func recordRenderedTree(root js.Value)
{
if root.Get("nodeType").Int() == 1 {
setRenderedAttributes(root, attributeSnapshot(root))
}
children := root.Get("childNodes")
for i := 0; i < children.Length(); i++ {
recordRenderedTree(children.Index(i))
}
}
recordRenderedTreeFromSource
func recordRenderedTreeFromSource(node, source js.Value)
{
if node.Get("nodeType").Int() == 1 && source.Get("nodeType").Int() == 1 {
setRenderedAttributes(node, attributeSnapshot(source))
}
nodeChildren := node.Get("childNodes")
sourceChildren := source.Get("childNodes")
limit := nodeChildren.Length()
if sourceChildren.Length() < limit {
limit = sourceChildren.Length()
}
for i := 0; i < limit; i++ {
recordRenderedTreeFromSource(nodeChildren.Index(i), sourceChildren.Index(i))
}
}
frameworkOwnedAttribute
Parameters
Returns
func frameworkOwnedAttribute(name string) bool
{
return strings.HasPrefix(name, "data-component-") ||
strings.HasPrefix(name, "data-key") ||
strings.HasPrefix(name, "data-for") ||
strings.HasPrefix(name, "data-condition") ||
strings.HasPrefix(name, "data-router-") ||
strings.HasPrefix(name, "data-bind-") ||
strings.HasPrefix(name, "data-on-")
}
captureLiveFormState
Returns
func captureLiveFormState(existing, replacement js.Value) *liveFormState
{
controlled := isControlledForm(existing) || isControlledForm(replacement)
tag := existing.Get("nodeName").String()
state := &liveFormState{}
switch tag {
case "INPUT":
if !controlled {
state.value = existing.Get("value").String()
state.hasValue = true
}
typ := strings.ToLower(existing.Get("type").String())
if !controlled && (typ == "checkbox" || typ == "radio") {
state.checked = existing.Get("checked").Bool()
state.hasChecked = true
}
case "TEXTAREA":
if !controlled {
state.value = existing.Get("value").String()
state.hasValue = true
}
case "SELECT":
if !controlled {
state.selectedIndex = existing.Get("selectedIndex").Int()
state.hasSelected = true
}
default:
return nil
}
active := js.Global().Get("document").Get("activeElement")
if active.Truthy() && active.Equal(existing) {
start := existing.Get("selectionStart")
end := existing.Get("selectionEnd")
if start.Type() == js.TypeNumber && end.Type() == js.TypeNumber {
state.selectionAt = start.Int()
state.selectionEnd = end.Int()
direction := existing.Get("selectionDirection")
if direction.Type() == js.TypeString {
state.selectionDir = direction.String()
}
state.hasCaret = true
}
}
return state
}
restoreLiveFormState
Parameters
func restoreLiveFormState(element js.Value, state liveFormState)
{
if state.hasValue && element.Get("value").String() != state.value {
element.Set("value", state.value)
}
if state.hasChecked && element.Get("checked").Bool() != state.checked {
element.Set("checked", state.checked)
}
if state.hasSelected && element.Get("selectedIndex").Int() != state.selectedIndex {
element.Set("selectedIndex", state.selectedIndex)
}
if state.hasCaret {
setter := element.Get("setSelectionRange")
if setter.Type() == js.TypeFunction {
if state.selectionDir != "" {
element.Call("setSelectionRange", state.selectionAt, state.selectionEnd, state.selectionDir)
} else {
element.Call("setSelectionRange", state.selectionAt, state.selectionEnd)
}
}
}
}
Uses
isControlledForm
Parameters
Returns
func isControlledForm(node js.Value) bool
{
if node.Get("nodeType").Int() != 1 {
return false
}
if node.Call("hasAttribute", "data-bind-store").Bool() || node.Call("hasAttribute", "data-bind-signal").Bool() {
return true
}
for _, name := range []string{"value", "checked"} {
value := attribute(node, name)
if strings.Contains(value, ":w") && (strings.Contains(value, "@store:") || strings.Contains(value, "@signal:")) {
return true
}
}
return false
}
BenchmarkPatchKeyedList
Parameters
func BenchmarkPatchKeyedList(b *testing.B)
{
for _, size := range []int{10, 100, 1000} {
b.Run(strconv.Itoa(size), func(b *testing.B) {
root := CreateElement("root")
componentID := "benchmark-keyed-" + strconv.Itoa(size)
root.SetAttr("data-component-id", componentID)
forward := benchmarkListHTML(componentID, size, false)
reverse := benchmarkListHTML(componentID, size, true)
root.SetHTML(strings.TrimSuffix(strings.TrimPrefix(forward, `<root data-component-id="`+componentID+`">`), "</root>"))
recordRenderedTree(root.Value)
Doc().Body().AppendChild(root)
b.Cleanup(func() { root.Call("remove") })
b.ResetTimer()
for i := 0; i < b.N; i++ {
if i%2 == 0 {
patchInnerHTML(root.Value, reverse)
} else {
patchInnerHTML(root.Value, forward)
}
}
})
}
}
benchmarkListHTML
Parameters
Returns
func benchmarkListHTML(componentID string, size int, reverse bool) string
{
var html strings.Builder
fmt.Fprintf(&html, `<root data-component-id="%s"><ul>`, componentID)
for position := 0; position < size; position++ {
item := position
if reverse {
item = size - position - 1
}
fmt.Fprintf(&html, `<li data-for="benchmark" data-key="%d">row-%d</li>`, item, item)
}
html.WriteString(`</ul></root>`)
return html.String()
}
StyleInline
StyleInline converts a map of CSS properties into an inline style string.
Keys and values are concatenated as “key:value” pairs separated by semicolons.
Parameters
Returns
func StyleInline(styles map[string]string) string
{
var b strings.Builder
first := true
for k, v := range styles {
if !first {
b.WriteByte(';')
}
first = false
b.WriteString(k)
b.WriteByte(':')
b.WriteString(v)
}
return b.String()
}
TestDocumentElementBasics
Parameters
func TestDocumentElementBasics(t *testing.T)
{
doc := Doc()
el := doc.CreateElement("div")
el.SetText("hello")
if got := el.Text(); got != "hello" {
t.Fatalf("Text() = %q", got)
}
}
TestDocumentHead
Parameters
func TestDocumentHead(t *testing.T)
{
doc := Doc()
if node := doc.Head().Get("nodeName").String(); node != "HEAD" {
t.Fatalf("Head() node = %q", node)
}
}
TestElementAttrsAndStyle
Parameters
func TestElementAttrsAndStyle(t *testing.T)
{
doc := Doc()
el := doc.CreateElement("div")
el.SetAttr("data-x", "y")
if got := el.Attr("data-x"); got != "y" {
t.Fatalf("Attr() = %q", got)
}
el.SetHTML("<span>ok</span>")
if got := el.HTML(); got != "<span>ok</span>" {
t.Fatalf("HTML() = %q", got)
}
el.SetStyle("color", "red")
if v := el.Get("style").Call("getPropertyValue", "color").String(); v != "red" {
t.Fatalf("style color = %q", v)
}
}
TestElementCollections
Parameters
func TestElementCollections(t *testing.T)
{
doc := Doc()
parent := doc.CreateElement("div")
parent.SetHTML("<span>a</span><span>b</span>")
spans := parent.QueryAll("span")
if spans.Length() != 2 {
t.Fatalf("Length() = %d", spans.Length())
}
second := spans.Index(1)
if second.Text() != "b" {
t.Fatalf("Index(1).Text() = %q", second.Text())
}
second.ToggleClass("x")
if !second.HasClass("x") {
t.Fatalf("ToggleClass/HasClass failed")
}
}
TestElementAppendChild
Parameters
func TestElementAppendChild(t *testing.T)
{
doc := Doc()
parent := doc.CreateElement("div")
child := doc.CreateElement("span")
parent.AppendChild(child)
if got := parent.Query("span"); !got.Truthy() {
t.Fatalf("AppendChild() did not append")
}
}
Event
Event wraps a browser event.
type Event struct
Methods
PreventDefault prevents the default action for the event.
func (Event) PreventDefault()
{ e.Call("preventDefault") }
StopPropagation stops the event from bubbling.
func (Event) StopPropagation()
{ e.Call("stopPropagation") }
eventOptions
Returns
func eventOptions() js.Dict
{
opts := js.NewDict()
opts.Set("bubbles", true)
opts.Set("cancelable", true)
return opts
}
mountHandlerRoot
func mountHandlerRoot(t *testing.T, html string) Element
{
t.Helper()
root := Doc().CreateElement("div")
root.SetHTML(html)
Doc().Body().AppendChild(root)
t.Cleanup(func() { root.Call("remove") })
return root
}
Uses
TestComponentHandlersAreScoped
Parameters
func TestComponentHandlersAreScoped(t *testing.T)
{
firstRoot := mountHandlerRoot(t, `<button data-on-click="save">one</button>`)
secondRoot := mountHandlerRoot(t, `<button data-on-click="save">two</button>`)
var first, second int
RegisterComponentHandlerFunc("first", "save", func() { first++ })
RegisterComponentHandlerFunc("second", "save", func() { second++ })
DelegateEvents("first", firstRoot.Value)
DelegateEvents("second", secondRoot.Value)
t.Cleanup(func() {
RemoveDelegatedEvents("first", firstRoot.Value)
RemoveDelegatedEvents("second", secondRoot.Value)
ReleaseComponentHandlers("first")
ReleaseComponentHandlers("second")
})
firstRoot.Query("button").Call("click")
secondRoot.Query("button").Call("click")
if first != 1 || second != 1 {
t.Fatalf("scoped handler counts = %d, %d", first, second)
}
}
TestGlobalHandlerRunsOnceAcrossNestedDelegates
Parameters
func TestGlobalHandlerRunsOnceAcrossNestedDelegates(t *testing.T)
{
root := mountHandlerRoot(t, `<div id="nested"><button data-on-click="page">next</button></div>`)
nested := root.Query("#nested")
calls := 0
RegisterHandlerFunc("page", func() { calls++ })
DelegateEvents("outer", root.Value)
DelegateEvents("inner", nested.Value)
t.Cleanup(func() {
RemoveDelegatedEvents("inner", nested.Value)
RemoveDelegatedEvents("outer", root.Value)
})
nested.Query("button").Call("click")
if calls != 1 {
t.Fatalf("global handler calls = %d, want 1", calls)
}
nested.Query("button").Call("click")
if calls != 2 {
t.Fatalf("global handler calls after a second click = %d, want 2", calls)
}
}
TestNestedDelegatesContinuePastClaimedBinding
Parameters
func TestNestedDelegatesContinuePastClaimedBinding(t *testing.T)
{
root := mountHandlerRoot(t, `<div id="nested" data-on-click="parent"><button data-on-click="child">next</button></div>`)
nested := root.Query("#nested")
var childCalls, parentCalls int
RegisterHandlerFunc("child", func() { childCalls++ })
RegisterHandlerFunc("parent", func() { parentCalls++ })
DelegateEvents("outer-ancestor", root.Value)
DelegateEvents("inner-ancestor", nested.Value)
t.Cleanup(func() {
RemoveDelegatedEvents("inner-ancestor", nested.Value)
RemoveDelegatedEvents("outer-ancestor", root.Value)
})
nested.Query("button").Call("click")
if childCalls != 1 || parentCalls != 1 {
t.Fatalf("handler calls = child %d, parent %d; want 1, 1", childCalls, parentCalls)
}
}
TestDelegatedEventModifiers
Parameters
func TestDelegatedEventModifiers(t *testing.T)
{
root := mountHandlerRoot(t, `<button data-on-click="save" data-on-click-modifiers="prevent,once">save</button>`)
var calls int
RegisterComponentHandlerFunc("modifiers", "save", func() { calls++ })
DelegateEvents("modifiers", root.Value)
t.Cleanup(func() {
RemoveDelegatedEvents("modifiers", root.Value)
ReleaseComponentHandlers("modifiers")
})
button := root.Query("button")
first := js.Get("MouseEvent").New("click", eventOptions().Value)
if allowed := button.Call("dispatchEvent", first).Bool(); allowed {
t.Fatal("prevent modifier did not cancel the event")
}
button.Call("dispatchEvent", js.Get("MouseEvent").New("click", eventOptions().Value))
if calls != 1 {
t.Fatalf("once handler calls = %d", calls)
}
}
TestDelegatedKeyAndTimingModifiers
Parameters
func TestDelegatedKeyAndTimingModifiers(t *testing.T)
{
root := mountHandlerRoot(t, `
<input data-on-keydown="submit" data-on-keydown-modifiers="enter">
<button id="debounce" data-on-click="search" data-on-click-modifiers="debounce,10">search</button>
<button id="throttle" data-on-click="refresh" data-on-click-modifiers="throttle,10">refresh</button>
`)
var submit, search, refresh int
RegisterComponentHandlerFunc("timing", "submit", func() { submit++ })
RegisterComponentHandlerFunc("timing", "search", func() { search++ })
RegisterComponentHandlerFunc("timing", "refresh", func() { refresh++ })
DelegateEvents("timing", root.Value)
t.Cleanup(func() {
RemoveDelegatedEvents("timing", root.Value)
ReleaseComponentHandlers("timing")
})
input := root.Query("input")
escape := eventOptions()
escape.Set("key", "Escape")
input.Call("dispatchEvent", js.Get("KeyboardEvent").New("keydown", escape.Value))
enter := eventOptions()
enter.Set("key", "Enter")
input.Call("dispatchEvent", js.Get("KeyboardEvent").New("keydown", enter.Value))
if submit != 1 {
t.Fatalf("enter handler calls = %d", submit)
}
debounce := root.Query("#debounce")
debounce.Call("click")
debounce.Call("click")
throttle := root.Query("#throttle")
throttle.Call("click")
throttle.Call("click")
time.Sleep(30 * time.Millisecond)
throttle.Call("click")
if search != 1 {
t.Fatalf("debounce handler calls = %d", search)
}
if refresh != 2 {
t.Fatalf("throttle handler calls = %d", refresh)
}
}
TestDelegatedFocusHandlerUsesCaptureListener
Parameters
func TestDelegatedFocusHandlerUsesCaptureListener(t *testing.T)
{
root := mountHandlerRoot(t, `<input data-on-focus="focus">`)
var calls int
RegisterComponentHandlerFunc("focus", "focus", func() { calls++ })
DelegateEvents("focus", root.Value)
t.Cleanup(func() {
RemoveDelegatedEvents("focus", root.Value)
ReleaseComponentHandlers("focus")
})
options := js.NewDict()
options.Set("bubbles", false)
root.Query("input").Call("dispatchEvent", js.Get("FocusEvent").New("focus", options.Value))
if calls != 1 {
t.Fatalf("focus handler calls = %d", calls)
}
}
TestExpandEvents
Parameters
func TestExpandEvents(t *testing.T)
{
cases := []struct{ in, want string }{
{`<button @on:click:save>`, `<button data-on-click="save">`},
{`<button @click:save>`, `<button data-on-click="save">`},
{`<input @on:keydown.enter:submit />`, `<input data-on-keydown="submit" data-on-keydown-modifiers="enter" />`},
{`<tr @on:click:openRow data-id="3">`, `<tr data-on-click="openRow" data-id="3">`},
{`plain text with an [email protected] stays`, `plain text with an [email protected] stays`},
}
for _, c := range cases {
if got := ExpandEvents(c.in); got != c.want {
t.Errorf("ExpandEvents(%q) = %q, want %q", c.in, got, c.want)
}
}
}
Document
Document wraps the global document object.
type Document struct
Methods
ByID fetches an element by id.
Parameters
Returns
func (Document) ByID(id string) Element
{
if !d.Truthy() {
return Element{js.Null()}
}
return Element{d.Call("getElementById", id)}
}
Query returns the first element matching the selector.
Parameters
Returns
func (Document) Query(sel string) Element
{
if !d.Truthy() {
return Element{js.Null()}
}
return Element{d.Call("querySelector", sel)}
}
QueryAll returns all elements matching the selector.
Parameters
Returns
func (Document) QueryAll(sel string) Element
{
if !d.Truthy() {
return Element{js.Null()}
}
return Element{d.Call("querySelectorAll", sel)}
}
ByClass returns all elements with the given class name.
Parameters
Returns
func (Document) ByClass(name string) Element
{
return Element{d.Call("getElementsByClassName", name)}
}
ByTag returns all elements with the given tag name.
Parameters
Returns
func (Document) ByTag(tag string) Element
{
return Element{d.Call("getElementsByTagName", tag)}
}
CreateElement creates a new element with the tag.
Parameters
Returns
func (Document) CreateElement(tag string) Element
{
return Element{d.Call("createElement", tag)}
}
Head returns the document's <head> element.
Returns
func (Document) Head() Element
{ return Element{d.Get("head")} }
Doc
Doc returns the global Document.
Returns
func Doc() Document
{ return Document{js.Doc()} }
Uses
BindStoreInputsForComponent
BindStoreInputsForComponent is a no-op outside wasm builds.
Parameters
func BindStoreInputsForComponent(string, any)
{}
BindStoreInputs
BindStoreInputs is a no-op outside wasm builds.
Parameters
func BindStoreInputs(any)
{}
SnapshotComponentSignals
SnapshotComponentSignals is a stub returning nil outside wasm builds.
Parameters
Returns
func SnapshotComponentSignals(string) map[string]any
{ return nil }
TestPlaceholder
Parameters
func TestPlaceholder(_ *testing.T)
{}
TestElementRemoveAttr
Parameters
func TestElementRemoveAttr(t *testing.T)
{
el := Doc().CreateElement("button")
el.SetAttr("disabled", "")
if !el.Call("hasAttribute", "disabled").Bool() {
t.Fatal("SetAttr did not set disabled")
}
el.RemoveAttr("disabled")
if el.Call("hasAttribute", "disabled").Bool() {
t.Fatal("RemoveAttr left the attribute in place")
}
}
TestElementMatches
Parameters
func TestElementMatches(t *testing.T)
{
el := Doc().CreateElement("div")
el.SetAttr("data-row", "1")
if !el.Matches("[data-row]") {
t.Fatal("Matches() = false for a matching selector")
}
if el.Matches("[data-other]") {
t.Fatal("Matches() = true for a non-matching selector")
}
}
TestMissingElementHelpersAreSafe
Parameters
func TestMissingElementHelpersAreSafe(t *testing.T)
{
el := Query("#definitely-not-in-the-document")
el.RemoveAttr("disabled")
if el.Matches("[data-row]") {
t.Fatal("Matches() = true on a missing element")
}
}
TestFromWrapsRawValue
Parameters
func TestFromWrapsRawValue(t *testing.T)
{
parent := Doc().CreateElement("div")
parent.SetHTML(`<span data-id="7" class="a">x</span>`)
raw := parent.Call("querySelector", "span")
el := From(raw)
if got := el.Data("id"); got != "7" {
t.Fatalf("Data(id) = %q", got)
}
if !el.HasClass("a") {
t.Fatal("HasClass(a) = false")
}
if c := el.Closest("div"); c.IsNull() {
t.Fatal("Closest(div) returned null")
}
}