core
packageAPI reference for the core
package.
Imports
(29)time
STD
sort
STD
sync
INT
github.com/rfwlab/rfw/v2/dom
INT
github.com/rfwlab/rfw/v2/js
STD
crypto/sha256
STD
fmt
STD
strings
STD
testing
INT
github.com/rfwlab/rfw/v2/state
STD
runtime/debug
STD
context
STD
encoding/json
STD
log
STD
regexp
STD
sync/atomic
STD
html
STD
strconv
INT
github.com/rfwlab/rfw/v2/rtmlast
INT
github.com/rfwlab/rfw/v2/rtmleval
STD
encoding/hex
STD
runtime
INT
github.com/rfwlab/rfw/v2/hostclient
PKG
github.com/tdewolff/minify/v2
PKG
github.com/tdewolff/minify/v2/css
PKG
github.com/tdewolff/minify/v2/js
STD
reflect
STD
errors
INT
github.com/rfwlab/rfw/v2/http
MustRegisterComponent
MustRegisterComponent registers a component constructor under the provided name
and panics if the component is already registered.
Parameters
func MustRegisterComponent(name string, ctor func() Component)
{
if err := RegisterComponent(name, ctor); err != nil {
panic(err)
}
}
Uses
ComponentStats
ComponentStats is a stub for non-wasm builds.
type ComponentStats struct
Fields
| Name | Type | Description |
|---|---|---|
| RenderCount | int | |
| TotalRender | time.Duration | |
| LastRender | time.Duration | |
| AverageRender | time.Duration | |
| Timeline | []ComponentTimelineEntry |
ComponentTimelineEntry
ComponentTimelineEntry is a stub for non-wasm builds.
type ComponentTimelineEntry struct
Fields
| Name | Type | Description |
|---|---|---|
| Kind | string | |
| Timestamp | time.Time | |
| Duration | time.Duration |
renderJob
type renderJob struct
Fields
| Name | Type | Description |
|---|---|---|
| id | string | |
| depth | int | |
| active | func() bool | |
| render | func() |
requestScheduledRender
Parameters
func requestScheduledRender(job renderJob)
{
if job.id == "" || job.active == nil || job.render == nil || !job.active() {
return
}
componentRenderScheduler.Lock()
componentRenderScheduler.pending[job.id] = job
if componentRenderScheduler.scheduled {
componentRenderScheduler.Unlock()
return
}
componentRenderScheduler.scheduled = true
componentRenderScheduler.Unlock()
var callback js.Func
callback = js.SafeFuncOf(func(js.Value, []js.Value) any {
defer callback.Release()
flushComponentRenders()
return nil
})
queueMicrotask := js.Global().Get("queueMicrotask")
if queueMicrotask.Type() == js.TypeFunction {
queueMicrotask.Invoke(callback.Value)
return
}
js.Global().Call("setTimeout", callback.Value, 0)
}
Uses
cancelComponentRender
Parameters
func cancelComponentRender(componentID string)
{
componentRenderScheduler.Lock()
delete(componentRenderScheduler.pending, componentID)
componentRenderScheduler.Unlock()
}
flushComponentRenders
func flushComponentRenders()
{
componentRenderScheduler.Lock()
pending := make([]renderJob, 0, len(componentRenderScheduler.pending))
for _, job := range componentRenderScheduler.pending {
pending = append(pending, job)
}
clear(componentRenderScheduler.pending)
componentRenderScheduler.scheduled = false
componentRenderScheduler.Unlock()
// Parents commit before descendants. Ownership boundaries keep a parent from
// touching child internals; the ordering makes both renders deterministic.
sort.Slice(pending, func(i, j int) bool {
if pending[i].depth != pending[j].depth {
return pending[i].depth < pending[j].depth
}
return pending[i].id < pending[j].id
})
for _, job := range pending {
if !job.active() {
continue
}
job.render()
}
}
componentDepth
Parameters
Returns
func componentDepth(component *HTMLComponent) int
{
depth := 0
for component != nil && component.parent != nil {
depth++
component = component.parent
}
return depth
}
mountedComponentDepth
Parameters
Returns
func mountedComponentDepth(componentID string) int
{
root := dom.ComponentRoot(componentID)
if root.IsNull() || root.IsUndefined() {
return 0
}
depth := 0
for parent := root.Get("parentElement"); parent.Truthy(); parent = parent.Get("parentElement") {
if parent.Call("hasAttribute", "data-component-id").Bool() {
depth++
}
}
return depth
}
renderGolden
Parameters
Returns
func renderGolden(t *testing.T, name, tpl string, props map[string]any) (string, *HTMLComponent)
{
t.Helper()
c := NewHTMLComponent(name, []byte(tpl), props)
c.Init(nil)
return c.Render(), c
}
expectGolden
Parameters
func expectGolden(t *testing.T, got, want string)
{
t.Helper()
if got != want {
t.Fatalf("golden mismatch:\n got: %q\nwant: %q", got, want)
}
}
TestGoldenStoreDirectives
Parameters
func TestGoldenStoreDirectives(t *testing.T)
{
st := state.NewStore("g1", state.WithModule("app"))
st.Set("v", "<i>x</i>")
st.Set("m", "<i>y</i>")
tpl := `<root><p>@store:app.g1.v</p><p>@rawstore:app.g1.m</p><input value="@store:app.g1.v:w"/></root>`
got, c := renderGolden(t, "GoldenStore", tpl, nil)
want := fmt.Sprintf(`<root data-component-id="%s"><p><span data-store="app.g1.v"><i>x</i></span></p><p><span data-store-raw="app.g1.m"><i>y</i></span></p><input value="@store:app.g1.v:w"/></root>
`, c.ID)
expectGolden(t, got, want)
}
TestGoldenSignalDirectives
Parameters
func TestGoldenSignalDirectives(t *testing.T)
{
sig := state.NewSignal("<b>s</b>")
tpl := `<root><p>@signal:v</p><input value="@signal:v:w"/></root>`
got, c := renderGolden(t, "GoldenSignal", tpl, map[string]any{"v": sig})
want := fmt.Sprintf(`<root data-component-id="%s"><p><span data-signal="v"><b>s</b></span></p><input value="@signal:v:w"/></root>
`, c.ID)
expectGolden(t, got, want)
}
TestGoldenExprDirective
Parameters
func TestGoldenExprDirective(t *testing.T)
{
tpl := `<root><p>@expr:n + 1</p></root>`
got, c := renderGolden(t, "GoldenExpr", tpl, map[string]any{"n": 2})
want := fmt.Sprintf(`<root data-component-id="%s"><p><span data-expr="expr-0">3</span></p></root>
`, c.ID)
expectGolden(t, got, want)
}
TestGoldenClassExprDirective
Parameters
func TestGoldenClassExprDirective(t *testing.T)
{
tpl := `<root><p class="@expr:ok ? 'on' : 'off'">t</p></root>`
got, c := renderGolden(t, "GoldenClassExpr", tpl, map[string]any{"ok": true})
want := fmt.Sprintf(`<root data-component-id="%s"><p class="on" data-expr-class="class-expr-0">t</p></root>
`, c.ID)
expectGolden(t, got, want)
}
TestGoldenPropDirectives
Parameters
func TestGoldenPropDirectives(t *testing.T)
{
tpl := `<root><p>{{p}}</p><p>@prop:p</p><p>@rawprop:m</p><p>@prop:missing</p></root>`
got, c := renderGolden(t, "GoldenProp", tpl, map[string]any{"p": "<u>p</u>", "m": "<u>m</u>"})
want := fmt.Sprintf(`<root data-component-id="%s"><p><u>p</u></p><p><u>p</u></p><p><u>m</u></p><p>@prop:missing</p></root>
`, c.ID)
expectGolden(t, got, want)
}
TestGoldenIncludeDirective
Parameters
func TestGoldenIncludeDirective(t *testing.T)
{
child := NewHTMLComponent("GoldenIncChild", []byte(`<root><em>child</em></root>`), nil)
tpl := `<root>@include:child</root>`
c := NewHTMLComponent("GoldenInc", []byte(tpl), nil)
c.Init(nil)
c.AddDependency("child", child)
got := c.Render()
want := fmt.Sprintf(`<root data-component-id="%s"><root data-component-id="%s"><em>child</em></root>
</root>
`, c.ID, child.ID)
expectGolden(t, got, want)
}
TestGoldenSlotDirective
Parameters
func TestGoldenSlotDirective(t *testing.T)
{
tpl := `<root><div>@slot:header fallback@endslot</div></root>`
c := NewHTMLComponent("GoldenSlot", []byte(tpl), nil)
c.Init(nil)
c.SetSlots(map[string]any{"header": "provided"})
got := c.Render()
want := fmt.Sprintf(`<root data-component-id="%s"><div>provided</div></root>
`, c.ID)
expectGolden(t, got, want)
// Without provided content the inline fallback is rendered.
c2 := NewHTMLComponent("GoldenSlotFallback", []byte(tpl), nil)
c2.Init(nil)
got2 := c2.Render()
want2 := fmt.Sprintf(`<root data-component-id="%s"><div> fallback</div></root>
`, c2.ID)
expectGolden(t, got2, want2)
}
TestGoldenForRangeDirective
Parameters
func TestGoldenForRangeDirective(t *testing.T)
{
tpl := `<root><ul>@for:i in 1..3 <li>@prop:i</li>@endfor</ul></root>`
got, c := renderGolden(t, "GoldenForRange", tpl, nil)
want := fmt.Sprintf(`<root data-component-id="%s"><ul> <li data-key="1">1</li> <li data-key="2">2</li> <li data-key="3">3</li></ul></root>
`, c.ID)
expectGolden(t, got, want)
}
TestGoldenForSliceDirective
Parameters
func TestGoldenForSliceDirective(t *testing.T)
{
st := state.NewStore("g2", state.WithModule("app"))
st.Set("items", []any{map[string]any{"t": "<b>a</b>"}})
tpl := `<root><ul>@for:i in store:app.g2.items <li>@prop:i.t</li>@endfor</ul></root>`
got, c := renderGolden(t, "GoldenForSlice", tpl, nil)
want := fmt.Sprintf(`<root data-component-id="%s"><ul><template data-for-anchor="for-%s-0"></template> <li data-key="0" data-for="for-%s-0"><b>a</b></li></ul></root>
`, c.ID, c.ID, c.ID)
expectGolden(t, got, want)
}
TestGoldenForMapDirective
Parameters
func TestGoldenForMapDirective(t *testing.T)
{
st := state.NewStore("g3", state.WithModule("app"))
st.Set("items", map[string]any{"k": "<b>v</b>"})
tpl := `<root><ul>@for:k,v in store:app.g3.items <li>@prop:k=@prop:v</li>@endfor</ul></root>`
got, c := renderGolden(t, "GoldenForMap", tpl, nil)
want := fmt.Sprintf(`<root data-component-id="%s"><ul><template data-for-anchor="for-%s-0"></template> <li data-key="k" data-for="for-%s-0">k=<b>v</b></li></ul></root>
`, c.ID, c.ID, c.ID)
expectGolden(t, got, want)
}
TestGoldenConditionalDirective
Parameters
func TestGoldenConditionalDirective(t *testing.T)
{
tpl := "<root>\n@if:prop:v==\"1\"\nOne\n@else-if:prop:v==\"2\"\nTwo\n@else\nOther\n@endif\n</root>"
got, c := renderGolden(t, "GoldenIf", tpl, map[string]any{"v": "2"})
conds := []string{`@if:prop:v=="1"`, `@else-if:prop:v=="2"`, ""}
// the trailing index numbers the block within the render pass: two @if
// blocks with the same condition are distinct blocks
condHash := sha256.Sum256([]byte(strings.Join(conds, "|")))
condID := fmt.Sprintf("cond-%x-0", condHash[:20])
want := fmt.Sprintf("<root data-component-id=\"%s\">\n<div data-condition=\"%s\" data-condition-branch=\"1\">Two\n</div></root>\n", c.ID, condID)
expectGolden(t, got, want)
}
TestGoldenEventDirectives
Parameters
func TestGoldenEventDirectives(t *testing.T)
{
tpl := `<root><button @on:click:save>s</button><button @click.stop:undo>u</button></root>`
got, c := renderGolden(t, "GoldenEvents", tpl, nil)
want := fmt.Sprintf(`<root data-component-id="%s"><button data-on-click="save">s</button><button data-on-click="undo" data-on-click-modifiers="stop">u</button></root>
`, c.ID)
expectGolden(t, got, want)
}
TestGoldenRtIsDirective
Parameters
func TestGoldenRtIsDirective(t *testing.T)
{
if err := RegisterComponent("GoldenRtIsChild", func() Component {
return NewHTMLComponent("GoldenRtIsChild", []byte(`<root><em>dyn</em></root>`), nil)
}); err != nil && !strings.Contains(err.Error(), "already registered") {
t.Fatalf("register: %v", err)
}
tpl := `<root><div rt-is="GoldenRtIsChild"></div></root>`
got, c := renderGolden(t, "GoldenRtIs", tpl, nil)
child := c.Dependencies["rtis-GoldenRtIsChild-0"].(*HTMLComponent)
want := fmt.Sprintf(`<root data-component-id="%s"><root data-component-id="%s"><em>dyn</em></root>
</div></root>
`, c.ID, child.ID)
expectGolden(t, got, want)
}
TestGoldenConstructorDirectives
Parameters
func TestGoldenConstructorDirectives(t *testing.T)
{
tpl := `<root><div [header] class="c"></div><li [key {i.ID}]></li><span [plugin:p.badge]></span></root>`
got, c := renderGolden(t, "GoldenConstructors", tpl, nil)
want := fmt.Sprintf(`<root data-component-id="%s"><div data-ref="header" class="c"></div><li data-key="{i.ID}"></li><span data-plugin="p.badge"></span></root>
`, c.ID)
expectGolden(t, got, want)
}
TestGoldenPluginDirectives
Parameters
func TestGoldenPluginDirectives(t *testing.T)
{
RegisterPluginVar("gplug", "team", "lions")
tpl := `<root><div @plugin:gplug.init>{plugin:gplug.team}</div></root>`
got, c := renderGolden(t, "GoldenPlugin", tpl, nil)
want := fmt.Sprintf(`<root data-component-id="%s"><div data-plugin-cmd="gplug.init">lions</div></root>
`, c.ID)
expectGolden(t, got, want)
}
TestGoldenHostDirectives
Parameters
func TestGoldenHostDirectives(t *testing.T)
{
tpl := `<root><p>{h:count}</p><button @h:reset>r</button></root>`
c := NewHTMLComponent("GoldenHost", []byte(tpl), map[string]any{"count": "5"})
c.Init(nil)
c.AddHostComponent("GoldenHostComp")
got := c.Render()
want := fmt.Sprintf(`<root data-component-id="%s"><p><span data-host-var="count" data-host-expected="5">5</span></p><button data-host-cmd="reset">r</button></root>
`, c.ID)
expectGolden(t, got, want)
}
TestElseIfRendering
Tests complex conditional scenarios including @else-if and nested blocks.
Parameters
func TestElseIfRendering(t *testing.T)
{
c := &HTMLComponent{Props: map[string]any{"val": "2"}, conditionContents: make(map[string]ConditionContent)}
template := `
@if:prop:val=="1"
One
@else-if:prop:val=="2"
Two
@else
Other
@endif`
out := replaceConditionals(template, c)
if strings.Contains(out, "One") || strings.Contains(out, "Other") {
t.Fatalf("unexpected branches rendered: %s", out)
}
if !strings.Contains(out, "Two") {
t.Fatalf("expected 'Two' branch, got %s", out)
}
}
TestNestedConditionals
Parameters
func TestNestedConditionals(t *testing.T)
{
props := map[string]any{"outer": "yes", "inner": "maybe"}
c := &HTMLComponent{Props: props, conditionContents: make(map[string]ConditionContent)}
template := `
@if:prop:outer=="yes"
Start
@if:prop:inner=="yes"
InnerYes
@else-if:prop:inner=="maybe"
InnerMaybe
@else
InnerNo
@endif
@else
OuterNo
@endif`
out := replaceConditionals(template, c)
if !strings.Contains(out, "Start") || !strings.Contains(out, "InnerMaybe") {
t.Fatalf("nested conditions not rendered as expected: %s", out)
}
if strings.Contains(out, "InnerYes") || strings.Contains(out, "InnerNo") || strings.Contains(out, "OuterNo") {
t.Fatalf("unexpected branches present: %s", out)
}
}
TestReplaceConstructors
Tests constructor decorators for refs and keyed lists.
Parameters
func TestReplaceConstructors(t *testing.T)
{
tpl := `<div [header] class="box"></div>`
out := replaceConstructors(tpl)
if !strings.Contains(out, `data-ref="header"`) || !strings.Contains(out, `class="box"`) {
t.Fatalf("unexpected constructor replacement: %s", out)
}
tpl = `<li [key {item.ID}]></li>`
out = replaceConstructors(tpl)
if out != `<li data-key="{item.ID}"></li>` {
t.Fatalf("expected data-key constructor, got %s", out)
}
}
TestPluginPlaceholders
Tests plugin placeholders for variables, commands and constructors.
Parameters
func TestPluginPlaceholders(t *testing.T)
{
RegisterPluginVar("soccer", "team", "lions")
tpl := `<div @plugin:soccer.init>{plugin:soccer.team}</div>`
out := replacePluginPlaceholders(tpl)
if !strings.Contains(out, "lions") {
t.Fatalf("plugin variable not replaced: %s", out)
}
if !strings.Contains(out, `data-plugin-cmd="soccer.init"`) {
t.Fatalf("plugin command not replaced: %s", out)
}
tpl = `<span [plugin:soccer.badge]></span>`
out = replaceConstructors(tpl)
if !strings.Contains(out, `data-plugin="soccer.badge"`) {
t.Fatalf("plugin constructor not replaced: %s", out)
}
}
TestForUnsetStoreKeyRendersNothing
A @for bound to a store key that was never set must render nothing: the raw
template row used to leak into the DOM and keyed patches never removed it.
Parameters
func TestForUnsetStoreKeyRendersNothing(t *testing.T)
{
state.NewStore("fornil", state.WithModule("app"))
tpl := []byte(`<root><div>@for:i in store:app.fornil.items
<span data-key="x">@prop:i.title</span>
@endfor</div></root>`)
c := NewHTMLComponent("ForNil", tpl, nil)
c.Init(nil)
html := c.Render()
if strings.Contains(html, "@prop") || strings.Contains(html, "@for") {
t.Fatalf("unset store key leaked template markup: %s", html)
}
}
TestForFieldsEscapedByDefault
Substituted values are HTML-escaped by default; @rawprop opts into markup.
Parameters
func TestForFieldsEscapedByDefault(t *testing.T)
{
st := state.NewStore("escfor", state.WithModule("app"))
st.Set("items", []any{map[string]any{"txt": "<img src=x>", "markup": "<b>ok</b>"}})
tpl := []byte(`<root><div>@for:i in store:app.escfor.items
<span data-key="k">@prop:i.txt|@rawprop:i.markup</span>
@endfor</div></root>`)
c := NewHTMLComponent("EscFor", tpl, nil)
c.Init(nil)
html := c.Render()
if !strings.Contains(html, "<img src=x>") {
t.Fatalf("field not escaped: %s", html)
}
if !strings.Contains(html, "<b>ok</b>") {
t.Fatalf("rawprop escaped: %s", html)
}
}
TestSignalEscapedByDefault
@signal values are escaped by default: a signal carrying user-supplied
markup must render as text, not execute as HTML.
Parameters
func TestSignalEscapedByDefault(t *testing.T)
{
sig := state.NewSignal("<img src=x onerror=alert(1)>")
tpl := []byte(`<root><div>@signal:v</div></root>`)
c := NewHTMLComponent("EscSignal", tpl, map[string]any{"v": sig})
c.Init(nil)
html := c.Render()
if !strings.Contains(html, "<img src=x onerror=alert(1)>") {
t.Fatalf("signal not escaped: %s", html)
}
if strings.Contains(html, "<img") {
t.Fatalf("signal injected markup: %s", html)
}
}
TestExprEscapedByDefault
@expr output is escaped by default, matching @store/@prop policy.
Parameters
func TestExprEscapedByDefault(t *testing.T)
{
tpl := []byte(`<root><div>@expr:msg</div></root>`)
c := NewHTMLComponent("EscExpr", tpl, map[string]any{"msg": "<b>bad</b>"})
c.Init(nil)
html := c.Render()
if !strings.Contains(html, "<b>bad</b>") {
t.Fatalf("expr not escaped: %s", html)
}
if strings.Contains(html, "<b>bad</b>") {
t.Fatalf("expr injected markup: %s", html)
}
}
TestSignalUpdateUsesTextContent
Signal updates go through textContent so later values cannot inject HTML.
Parameters
func TestSignalUpdateUsesTextContent(t *testing.T)
{
sig := state.NewSignal("safe")
tpl := []byte(`<root><div>@signal:v</div></root>`)
c := NewHTMLComponent("EscSignalUpdate", tpl, map[string]any{"v": sig})
c.Init(nil)
html := c.Render()
container := dom.Doc().CreateElement("div")
container.Set("innerHTML", html)
dom.Doc().Get("body").Call("appendChild", container.Value)
defer container.Call("remove")
sig.Set("<img src=x onerror=alert(1)>")
root := dom.ComponentRoot(c.ID)
node := root.Query(`[data-signal="v"]`)
if node.IsNull() || node.IsUndefined() {
t.Fatalf("signal binding not found")
}
inner := node.Get("innerHTML").String()
if !strings.Contains(inner, "<img") {
t.Fatalf("signal update not applied as text: %s", inner)
}
}
TestCurlyPropEscapedByDefault
{{prop}} substitutions are escaped by default like @prop; @rawprop stays
the trusted markup escape hatch.
Parameters
func TestCurlyPropEscapedByDefault(t *testing.T)
{
tpl := []byte(`<root><div>{{msg}}|@rawprop:markup</div></root>`)
c := NewHTMLComponent("EscCurly", tpl, map[string]any{
"msg": "<script>alert(1)</script>",
"markup": "<b>ok</b>",
})
c.Init(nil)
html := c.Render()
if !strings.Contains(html, "<script>alert(1)</script>") {
t.Fatalf("curly prop not escaped: %s", html)
}
if !strings.Contains(html, "<b>ok</b>") {
t.Fatalf("rawprop escaped: %s", html)
}
}
TestForMapFieldsEscapedByDefault
@for over a map collection escapes keys, scalar values and map fields just
like the slice path; @rawprop opts into trusted markup.
Parameters
func TestForMapFieldsEscapedByDefault(t *testing.T)
{
st := state.NewStore("escformap", state.WithModule("app"))
st.Set("items", map[string]any{
"<key>": map[string]any{"txt": "<img src=x>", "markup": "<b>ok</b>"},
})
tpl := []byte(`<root><div>@for:k,v in store:app.escformap.items
<span data-key="m">@prop:k|@prop:v.txt|@rawprop:v.markup</span>
@endfor</div></root>`)
c := NewHTMLComponent("EscForMap", tpl, nil)
c.Init(nil)
html := c.Render()
if !strings.Contains(html, "<key>") {
t.Fatalf("map key not escaped: %s", html)
}
if !strings.Contains(html, "<img src=x>") {
t.Fatalf("map field not escaped: %s", html)
}
if !strings.Contains(html, "<b>ok</b>") {
t.Fatalf("rawprop escaped: %s", html)
}
}
TestForMapScalarsEscapedByDefault
@for over a map of scalars escapes values by default.
Parameters
func TestForMapScalarsEscapedByDefault(t *testing.T)
{
st := state.NewStore("escformapscalar", state.WithModule("app"))
st.Set("items", map[string]any{"a": "<i>x</i>"})
tpl := []byte(`<root><div>@for:k,v in store:app.escformapscalar.items
<span data-key="s">@prop:v</span>
@endfor</div></root>`)
c := NewHTMLComponent("EscForMapScalar", tpl, nil)
c.Init(nil)
html := c.Render()
if !strings.Contains(html, "<i>x</i>") {
t.Fatalf("map scalar not escaped: %s", html)
}
}
TestStoreEscapedByDefault
@store values are escaped by default; @rawstore injects trusted markup.
Parameters
func TestStoreEscapedByDefault(t *testing.T)
{
st := state.NewStore("escstore", state.WithModule("app"))
st.Set("v", "<i>x</i>")
st.Set("m", "<i>y</i>")
tpl := []byte(`<root><div>@store:app.escstore.v @rawstore:app.escstore.m</div></root>`)
c := NewHTMLComponent("EscStore", tpl, nil)
c.Init(nil)
html := c.Render()
if !strings.Contains(html, "<i>x</i>") {
t.Fatalf("store not escaped: %s", html)
}
if !strings.Contains(html, "<i>y</i>") {
t.Fatalf("rawstore escaped: %s", html)
}
}
TestHyphenatedStorePathsResolve
@for has always split store paths by hand, so it accepted a hyphenated store
name while @store, @rawstore and conditions matched \w and quietly left the
directive in the markup. A binding that renders as its own source text is
worse than one that fails, so the three agree on the character set now.
Parameters
func TestHyphenatedStorePathsResolve(t *testing.T)
{
st := state.NewStore("hyphen-store", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "hyphen-store")
st.Set("first-name", "Mirko")
st.Set("markup", "<i>y</i>")
tpl := []byte(`<root>
@if:store:app.hyphen-store.first-name == "Mirko"
<div data-hyphen>@store:app.hyphen-store.first-name @rawstore:app.hyphen-store.markup</div>
@endif
</root>`)
c := NewHTMLComponent("HyphenStore", tpl, nil)
c.Init(nil)
html := c.Render()
if strings.Contains(html, "@store:") || strings.Contains(html, "@rawstore:") {
t.Fatalf("hyphenated store path left in the markup: %s", html)
}
if !strings.Contains(html, "data-hyphen") {
t.Fatalf("condition on a hyphenated store did not select the branch: %s", html)
}
if !strings.Contains(html, "Mirko") {
t.Fatalf("hyphenated store value missing: %s", html)
}
if !strings.Contains(html, "<i>y</i>") {
t.Fatalf("hyphenated rawstore escaped: %s", html)
}
}
TestHyphenatedStoreWriteBindingUpdatesStore
The two-way binding is parsed again from the rendered attribute, so the DOM
side has to accept the same paths the template does.
Parameters
func TestHyphenatedStoreWriteBindingUpdatesStore(t *testing.T)
{
st := state.NewStore("hyphen-write", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "hyphen-write")
st.Set("query-text", "")
tpl := []byte(`<root><input data-hyphen-input value="@store:app.hyphen-write.query-text:w"></root>`)
c := mountForComponent(t, "HyphenWrite", tpl)
defer c.Unmount()
input := dom.Query("[data-hyphen-input]")
input.Set("value", "typed")
input.Call("dispatchEvent", js.CustomEvent().New("input"))
if got := st.Get("query-text"); got != "typed" {
t.Fatalf("store key = %v, want %q", got, "typed")
}
}
TestConditionOnHyphenatedStoreReacts
A condition’s dependencies come from a regex of their own, so a hyphenated
store also has to register the subscription that repaints the branch when
the value moves. Without it the block renders once and then freezes.
Parameters
func TestConditionOnHyphenatedStoreReacts(t *testing.T)
{
st := state.NewStore("hyphen-cond", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "hyphen-cond")
st.Set("chrome-state", "on")
tpl := []byte(`<root>
@if:store:app.hyphen-cond.chrome-state == "on"
<header data-hyphen-header>shown</header>
@endif
</root>`)
c := mountForComponent(t, "HyphenCond", tpl)
defer c.Unmount()
if dom.Query("[data-hyphen-header]").IsNull() {
t.Fatal("header not rendered while the condition holds")
}
st.Set("chrome-state", "off")
waitForRenderFlush()
if el := dom.Query("[data-hyphen-header]"); !el.IsNull() {
t.Fatal("header stayed after the hyphenated store key changed")
}
}
Version
Version returns the framework version for this build.
Returns
func Version() string
{
if info, ok := debug.ReadBuildInfo(); ok {
v := info.Main.Version
if v != "" && v != "(devel)" {
return v
}
}
return version
}
Portal
Portal renders a child into a DOM target outside its component tree.
type Portal struct
Methods
Render returns the portal anchor markup.
Returns
func (*Portal) Render() string
{
return `<root data-component-id="` + portal.id + `"><template data-portal-anchor></template></root>`
}
Mount renders the child into the portal target.
func (*Portal) Mount()
{
if portal.mounted || portal.child == nil {
return
}
target := dom.Query(portal.selector)
if target.IsNull() || target.IsUndefined() {
return
}
container := dom.CreateElement("div")
container.SetAttr("data-portal-id", portal.id)
target.AppendChild(container)
portal.container = container
dom.UpdateDOMIn(container, portal.child.GetID(), TryRender(portal.child))
portal.child.Mount()
portal.mounted = true
}
Unmount removes the portal child and container.
func (*Portal) Unmount()
{
if !portal.mounted {
return
}
portal.child.Unmount()
if !portal.container.IsNull() && !portal.container.IsUndefined() {
portal.container.Call("remove")
}
portal.container = dom.Element{}
portal.mounted = false
}
GetName returns the portal component name.
Returns
func (*Portal) GetName() string
{
return "Portal"
}
GetID returns the portal component ID.
Returns
func (*Portal) GetID() string
{ return portal.id }
SetSlots forwards slots to the child component.
Parameters
func (*Portal) SetSlots(slots map[string]any)
{
if portal.child != nil {
portal.child.SetSlots(slots)
}
}
IsMounted reports whether the portal is mounted.
Returns
func (*Portal) IsMounted() bool
{ return portal.mounted }
OnParams forwards route parameters to the child.
Parameters
func (*Portal) OnParams(params map[string]string)
{
if portal.child != nil {
portal.child.OnParams(params)
}
}
Fields
| Name | Type | Description |
|---|---|---|
| id | string | |
| selector | string | |
| child | Component | |
| container | dom.Element | |
| mounted | bool |
Uses
NewPortal
NewPortal creates a portal targeting a CSS selector.
func NewPortal(selector string, child Component) *Portal
{
return &Portal{
id: generateComponentID("Portal", map[string]any{"target": selector}),
selector: selector,
child: child,
}
}
Uses
KeepAliveAware
KeepAliveAware receives activation events without being unmounted.
type KeepAliveAware interface
Methods
func OnActivate(...)
func OnDeactivate(...)
KeepAlive
KeepAlive preserves a child’s DOM and component state across route swaps.
type KeepAlive struct
Methods
Render returns the wrapper markup for the cached child.
Returns
func (*KeepAlive) Render() string
{
content := ""
if !keep.cached && !keep.disposed && keep.child != nil {
content = TryRender(keep.child)
}
return `<root data-component-id="` + keep.id + `"><div data-keepalive-host>` + content + `</div></root>`
}
Mount restores or initializes the cached child.
func (*KeepAlive) Mount()
{
if keep.mounted || keep.disposed || keep.child == nil {
return
}
if keep.cached && keep.fragment.Truthy() {
root := dom.ComponentRoot(keep.id)
host := root.Query("[data-keepalive-host]")
if !host.IsNull() && !host.IsUndefined() {
host.Call("appendChild", keep.fragment)
}
keep.cached = false
}
if !keep.initialized {
keep.child.Mount()
keep.initialized = true
} else if aware, ok := keep.child.(KeepAliveAware); ok {
aware.OnActivate()
}
keep.mounted = true
}
Unmount detaches the child while retaining its state.
func (*KeepAlive) Unmount()
{
if !keep.mounted || keep.disposed || keep.child == nil {
return
}
root := dom.ComponentRoot(keep.id)
if root.Attr("data-component-id") == keep.id {
childRoot := root.Query(`[data-component-id="` + keep.child.GetID() + `"]`)
if !childRoot.IsNull() && !childRoot.IsUndefined() {
fragment := js.Document().Call("createDocumentFragment")
fragment.Call("appendChild", childRoot.Value)
keep.fragment = fragment
keep.cached = true
}
}
if aware, ok := keep.child.(KeepAliveAware); ok {
aware.OnDeactivate()
}
keep.mounted = false
}
Dispose permanently unmounts the cached child.
func (*KeepAlive) Dispose()
{
if keep.disposed {
return
}
root := dom.ComponentRoot(keep.id)
if keep.cached && keep.fragment.Truthy() {
holder := dom.CreateElement("div")
holder.SetStyle("display", "none")
dom.Doc().Body().AppendChild(holder)
holder.Call("appendChild", keep.fragment)
keep.child.Unmount()
holder.Call("remove")
} else if keep.initialized {
keep.child.Unmount()
}
keep.fragment = js.Undefined()
keep.cached = false
keep.mounted = false
keep.disposed = true
if root.Attr("data-component-id") == keep.id {
root.Call("remove")
}
}
OnUnmount handles the component lifecycle callback.
func (*KeepAlive) OnUnmount()
{}
GetName returns the component name.
Returns
func (*KeepAlive) GetName() string
{
return "KeepAlive"
}
GetID returns the component identifier.
Returns
func (*KeepAlive) GetID() string
{ return keep.id }
SetSlots forwards slots to the cached child.
Parameters
func (*KeepAlive) SetSlots(slots map[string]any)
{
if keep.child != nil {
keep.child.SetSlots(slots)
}
}
IsMounted reports whether the component is mounted.
Returns
func (*KeepAlive) IsMounted() bool
{ return keep.mounted }
OnParams forwards route parameters to the cached child.
Parameters
func (*KeepAlive) OnParams(params map[string]string)
{
if keep.child != nil {
keep.child.OnParams(params)
}
}
Fields
Uses
NewKeepAlive
NewKeepAlive creates a state-preserving component wrapper.
func NewKeepAlive(child Component) *KeepAlive
{
return &KeepAlive{id: generateComponentID("KeepAlive", nil), child: child}
}
Uses
TransitionConfig
TransitionConfig names the CSS classes used during enter and leave phases.
type TransitionConfig struct
Fields
| Name | Type | Description |
|---|---|---|
| EnterFrom | string | |
| EnterActive | string | |
| EnterTo | string | |
| LeaveFrom | string | |
| LeaveActive | string | |
| LeaveTo | string | |
| Duration | time.Duration |
DefaultTransitionConfig
DefaultTransitionConfig returns class names compatible with plain CSS.
Returns
func DefaultTransitionConfig() TransitionConfig
{
return TransitionConfig{
EnterFrom: "rfw-enter-from",
EnterActive: "rfw-enter-active",
EnterTo: "rfw-enter-to",
LeaveFrom: "rfw-leave-from",
LeaveActive: "rfw-leave-active",
LeaveTo: "rfw-leave-to",
Duration: 200 * time.Millisecond,
}
}
Transition
Transition applies CSS enter and leave phases around a child component.
type Transition struct
Methods
Render returns the transition wrapper markup.
Returns
func (*Transition) Render() string
{
content := ""
if transition.child != nil {
content = TryRender(transition.child)
}
return `<root data-component-id="` + transition.id + `" data-transition>` + content + `</root>`
}
Mount inserts the child and applies enter classes.
func (*Transition) Mount()
{
if transition.mounted || transition.child == nil {
return
}
if transition.timer != nil {
transition.timer.Stop()
transition.timer = nil
}
if transition.leaving.Attr("data-component-id") == transition.id {
transition.child.Unmount()
transition.leaving.Call("remove")
transition.leaving = dom.Element{}
}
transition.mounted = true
transition.child.Mount()
root := dom.ComponentRoot(transition.id)
addClasses(root, transition.config.EnterFrom, transition.config.EnterActive)
js.OnAnimationFrame(func() {
if !transition.mounted {
return
}
removeClasses(root, transition.config.EnterFrom)
addClasses(root, transition.config.EnterTo)
})
transition.timer = time.AfterFunc(transition.config.Duration, func() {
if transition.mounted {
removeClasses(root, transition.config.EnterActive, transition.config.EnterTo)
}
})
}
Unmount applies leave classes before removing the child.
func (*Transition) Unmount()
{
if !transition.mounted || transition.child == nil {
return
}
transition.mounted = false
if transition.timer != nil {
transition.timer.Stop()
}
root := dom.ComponentRoot(transition.id)
if root.Attr("data-component-id") != transition.id {
transition.child.Unmount()
return
}
dom.Doc().Body().AppendChild(root)
transition.leaving = root
removeClasses(root, transition.config.EnterFrom, transition.config.EnterActive, transition.config.EnterTo)
addClasses(root, transition.config.LeaveFrom, transition.config.LeaveActive)
js.OnAnimationFrame(func() {
removeClasses(root, transition.config.LeaveFrom)
addClasses(root, transition.config.LeaveTo)
})
transition.timer = time.AfterFunc(transition.config.Duration, func() {
transition.child.Unmount()
root.Call("remove")
removeClasses(root, transition.config.LeaveActive, transition.config.LeaveTo)
transition.leaving = dom.Element{}
transition.timer = nil
})
}
Dispose removes a transition immediately.
func (*Transition) Dispose()
{
if transition.timer != nil {
transition.timer.Stop()
transition.timer = nil
}
if transition.child != nil && transition.child.IsMounted() {
transition.child.Unmount()
}
root := dom.ComponentRoot(transition.id)
if root.Attr("data-component-id") == transition.id {
root.Call("remove")
}
transition.leaving = dom.Element{}
transition.mounted = false
}
OnUnmount handles the component lifecycle callback.
func (*Transition) OnUnmount()
{}
GetName returns the component name.
Returns
func (*Transition) GetName() string
{
return "Transition"
}
GetID returns the component identifier.
Returns
func (*Transition) GetID() string
{ return transition.id }
SetSlots forwards slots to the child.
Parameters
func (*Transition) SetSlots(slots map[string]any)
{
if transition.child != nil {
transition.child.SetSlots(slots)
}
}
IsMounted reports whether the component is mounted.
Returns
func (*Transition) IsMounted() bool
{ return transition.mounted }
OnParams forwards route parameters to the child.
Parameters
func (*Transition) OnParams(params map[string]string)
{
if transition.child != nil {
transition.child.OnParams(params)
}
}
Fields
| Name | Type | Description |
|---|---|---|
| id | string | |
| child | Component | |
| config | TransitionConfig | |
| mounted | bool | |
| timer | *time.Timer | |
| leaving | dom.Element |
NewTransition
NewTransition creates a CSS transition wrapper.
Parameters
Returns
func NewTransition(child Component, config TransitionConfig) *Transition
{
defaults := DefaultTransitionConfig()
if config.EnterFrom == "" {
config.EnterFrom = defaults.EnterFrom
}
if config.EnterActive == "" {
config.EnterActive = defaults.EnterActive
}
if config.EnterTo == "" {
config.EnterTo = defaults.EnterTo
}
if config.LeaveFrom == "" {
config.LeaveFrom = defaults.LeaveFrom
}
if config.LeaveActive == "" {
config.LeaveActive = defaults.LeaveActive
}
if config.LeaveTo == "" {
config.LeaveTo = defaults.LeaveTo
}
if config.Duration == 0 {
config.Duration = defaults.Duration
}
return &Transition{
id: generateComponentID("Transition", nil),
child: child,
config: config,
}
}
addClasses
Parameters
func addClasses(element dom.Element, groups ...string)
{
for _, group := range groups {
for _, className := range strings.Fields(group) {
element.AddClass(className)
}
}
}
removeClasses
Parameters
func removeClasses(element dom.Element, groups ...string)
{
for _, group := range groups {
for _, className := range strings.Fields(group) {
element.RemoveClass(className)
}
}
}
TestMountedDependencyConditionUpdatesDOM
The shell case: a mounted parent re-renders because one of its own store
lists changed, and an included dependency gates its markup on another key of
the same store. The dependency has to follow the store in the DOM, not just
in a fresh render.
Parameters
func TestMountedDependencyConditionUpdatesDOM(t *testing.T)
{
store := state.NewStore("depdom", state.WithModule("app"))
store.Set("chrome", "on")
store.Set("nav", []any{map[string]any{"label": "one"}})
defer state.GlobalStoreManager.UnregisterStore("app", "depdom")
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
child := NewHTMLComponent("DomChild", []byte(`<root>
@if:store:app.depdom.chrome == "on"
<span id="dep-block">visible</span>
@endif
</root>`), nil)
child.SetComponent(child)
child.Init(nil)
parent := NewHTMLComponent("DomParent", []byte(`<root>
@for:it in store:app.depdom.nav
<span class="nav">@prop:it.label</span>
@endfor
@include:child
</root>`), nil)
parent.SetComponent(parent)
parent.AddDependency("child", child)
parent.Init(nil)
dom.UpdateDOM(parent.GetID(), parent.Render())
parent.Mount()
defer parent.Unmount()
if html := dom.ComponentRoot(parent.GetID()).HTML(); !strings.Contains(html, "dep-block") {
t.Fatalf("dependency did not render: %s", html)
}
store.Set("chrome", "off")
store.Set("nav", []any{map[string]any{"label": "one"}, map[string]any{"label": "two"}})
waitForRenderFlush()
html := dom.ComponentRoot(parent.GetID()).HTML()
if !strings.Contains(html, "two") {
t.Fatalf("parent did not re-render: %s", html)
}
if strings.Contains(html, "dep-block") {
t.Fatalf("dependency kept its stale markup after the store changed: %s", html)
}
}
TestComponentDOMHookLifecycle
Parameters
func TestComponentDOMHookLifecycle(t *testing.T)
{
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
component := NewHTMLComponent("Hooked", []byte("<root><p>hooked</p></root>"), nil)
component.SetComponent(component)
component.Init(nil)
mounted := 0
updated := 0
unmounted := 0
cleaned := 0
component.DOMHook(dom.LifecycleHook{
Mounted: func(root dom.Element) func() {
if root.Attr("data-component-id") != component.ID {
t.Fatalf("hook received wrong root: %q", root.Attr("data-component-id"))
}
mounted++
return func() { cleaned++ }
},
Updated: func(dom.Element) {
updated++
},
Unmounted: func(dom.Element) {
unmounted++
},
})
dom.UpdateDOM(component.ID, component.Render())
component.Mount()
dom.UpdateMountedDOM(component.ID, component.RenderFresh())
component.Unmount()
if mounted != 1 || updated != 1 || unmounted != 1 || cleaned != 1 {
t.Fatalf("unexpected hook counts: mount=%d update=%d unmount=%d cleanup=%d", mounted, updated, unmounted, cleaned)
}
}
TestUnmountCleanupContinuesAfterLifecyclePanic
Parameters
func TestUnmountCleanupContinuesAfterLifecyclePanic(t *testing.T)
{
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
component := NewHTMLComponent("PanicCleanup", []byte("<root></root>"), nil)
component.SetComponent(component)
component.Init(nil)
cleaned := false
component.Scope().Defer(func() { cleaned = true })
component.SetOnUnmount(func(*HTMLComponent) { panic("unmount") })
stopErrors := OnError(func(any, string) {})
defer stopErrors()
dom.UpdateDOM(component.ID, component.Render())
component.Mount()
component.Unmount()
if !cleaned {
t.Fatal("scope cleanup stopped after lifecycle panic")
}
}
ShowErrorOverlay
ShowErrorOverlay displays a styled error recovery UI in the browser when a
panic occurs. It categorizes the error, shows the Go stack trace, and
provides actionable hints based on the panic message.
Parameters
func ShowErrorOverlay(err any, context string)
{
globalOverlay.show(err, context)
}
errorOverlay
type errorOverlay struct
Methods
Parameters
func (*errorOverlay) show(err any, context string)
{
errStr := fmt.Sprintf("%v", err)
goStack := string(debug.Stack())
if stacked, ok := err.(interface{ Stack() []byte }); ok {
goStack = string(stacked.Stack())
}
if !eo.shown {
eo.shown = true
eo.createContainer(errStr, goStack, context)
return
}
eo.errCount++
doc := js.Document()
list := doc.Call("getElementById", "rfw-error-list")
if !list.Truthy() {
return
}
item := doc.Call("createElement", "div")
item.Get("style").Set("borderTop", "1px solid #e5e7eb")
item.Set("innerHTML", eo.buildErrorItem(eo.errCount, errStr, goStack, context))
list.Call("appendChild", item)
}
Parameters
func (*errorOverlay) createContainer(errStr, goStack, context string)
{
doc := js.Document()
body := doc.Get("body")
overlay := doc.Call("createElement", "div")
overlay.Set("id", "rfw-error-overlay")
style := overlay.Get("style")
style.Set("position", "fixed")
style.Set("top", "0")
style.Set("left", "0")
style.Set("width", "100%")
style.Set("height", "100%")
style.Set("backgroundColor", "rgba(0,0,0,0.85)")
style.Set("zIndex", "999999")
style.Set("display", "flex")
style.Set("alignItems", "center")
style.Set("justifyContent", "center")
style.Set("padding", "20px")
style.Set("boxSizing", "border-box")
style.Set("fontFamily", "system-ui,-apple-system,sans-serif")
card := doc.Call("createElement", "div")
cs := card.Get("style")
cs.Set("background", "#ffffff")
cs.Set("borderRadius", "12px")
cs.Set("boxShadow", "0 20px 60px rgba(0,0,0,0.3)")
cs.Set("maxWidth", "900px")
cs.Set("width", "100%")
cs.Set("maxHeight", "90vh")
cs.Set("overflow", "auto")
cs.Set("display", "flex")
cs.Set("flexDirection", "column")
card.Set("innerHTML", eo.buildMainHTML(errStr, goStack, context))
overlay.Call("appendChild", card)
body.Call("appendChild", overlay)
eo.bindActions(overlay)
eo.container = overlay
}
Parameters
func (*errorOverlay) bindActions(js.Value)
{
doc := js.Document()
reload := doc.Call("getElementById", "rfw-error-reload")
if reload.Truthy() {
reload.Call("addEventListener", "click", js.SafeFuncOf(func(_ js.Value, _ []js.Value) any {
js.Location().Call("reload")
return nil
}))
}
copyBtn := doc.Call("getElementById", "rfw-error-copy")
if copyBtn.Truthy() {
copyBtn.Call("addEventListener", "click", js.SafeFuncOf(func(_ js.Value, _ []js.Value) any {
pre := doc.Call("getElementById", "rfw-error-full")
if pre.Truthy() {
text := pre.Get("textContent").String()
navigator := js.Global().Get("navigator")
if clipboard := navigator.Get("clipboard"); clipboard.Truthy() {
clipboard.Call("writeText", text)
}
}
return nil
}))
}
}
Parameters
Returns
func (*errorOverlay) buildMainHTML(errStr, goStack, context string) string
{
cat := eo.categorize(errStr)
hint := eo.hintHTML(errStr, context)
versionStr := Version()
if versionStr == "" {
versionStr = "dev"
}
return fmt.Sprintf(`
<div style="padding:24px 24px 0;">
<div style="display:flex;align-items:flex-start;gap:12px;margin-bottom:16px;">
<div style="flex:1;">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:#6b7280;font-weight:700;">%s</div>
<h2 style="margin:4px 0 0;font-size:20px;color:#111827;font-weight:800;">Something went wrong</h2>
</div>
</div>
<div style="background:#fef2f2;border-left:4px solid #ef4444;border-radius:6px;padding:16px;margin-bottom:16px;">
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;color:#991b1b;word-break:break-word;line-height:1.5;">%s</div>
</div>
</div>
%s
<div style="padding:0 24px;">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;">
<button id="rfw-error-reload" style="background:#111827;color:#fff;border:none;border-radius:6px;padding:10px 18px;cursor:pointer;font-size:14px;font-weight:500;">Reload Page</button>
<button id="rfw-error-copy" style="background:#f3f4f6;color:#374151;border:none;border-radius:6px;padding:10px 18px;cursor:pointer;font-size:14px;font-weight:500;">Copy Error</button>
</div>
</div>
<div style="padding:0 24px 16px;">
<div style="font-size:13px;font-weight:700;color:#374151;margin-bottom:8px;">Stack Trace</div>
<pre style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:12px;overflow-x:auto;font-size:11px;color:#4b5563;margin:0;line-height:1.5;">%s</pre>
</div>
<pre id="rfw-error-full" style="display:none;">%s</pre>
<div id="rfw-error-list" style="display:none;"></div>
<div style="padding:8px 24px 16px;text-align:center;">
<div style="font-size:11px;color:#9ca3af;">
rfw recovery mode · %s
</div>
</div>
`, cat, htmlEscape(errStr), hint,
htmlEscape(goStack),
htmlEscape(fmt.Sprintf("Error: %s\nContext: %s\n\n%s", errStr, context, goStack)),
versionStr)
}
Parameters
Returns
func (*errorOverlay) buildErrorItem(n int, errStr, goStack, _ string) string
{
return fmt.Sprintf(`
<div style="padding:16px;">
<div style="font-size:12px;font-weight:700;color:#6b7280;margin-bottom:8px;">Error #%d</div>
<div style="background:#fef2f2;border-radius:6px;padding:12px;margin-bottom:8px;">
<div style="font-family:monospace;font-size:12px;color:#991b1b;word-break:break-word;">%s</div>
</div>
<details open>
<summary style="cursor:pointer;font-size:12px;color:#6b7280;">Stack trace</summary>
<pre style="background:#f9fafb;border-radius:6px;padding:8px;font-size:11px;color:#4b5563;margin-top:8px;">%s</pre>
</details>
</div>
`, n, htmlEscape(errStr), htmlEscape(goStack))
}
Parameters
Returns
func (*errorOverlay) categorize(err string) string
{
errLower := strings.ToLower(err)
switch {
case strings.Contains(errLower, "template"):
return "Template Error"
case strings.Contains(errLower, "signal"):
return "Signal Error"
case strings.Contains(errLower, "store"):
return "Store Error"
case strings.Contains(errLower, "nil pointer") || strings.Contains(errLower, "invalid memory"):
return "Null Reference"
case strings.Contains(errLower, "index out of range"):
return "Index Error"
case strings.Contains(errLower, "mount") || strings.Contains(errLower, "render") || strings.Contains(errLower, "unmount"):
return "Lifecycle Error"
case strings.Contains(errLower, "dom") || strings.Contains(errLower, "element"):
return "DOM Error"
default:
return "Runtime Error"
}
}
Parameters
Returns
func (*errorOverlay) hintHTML(errStr, _ string) string
{
errLower := strings.ToLower(errStr)
hints := []string{}
if strings.Contains(errLower, "template") && strings.Contains(errLower, "not found") {
hints = append(hints, `Call <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">composition.RegisterFS(&yourEmbedFS)</code> or add a <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">Template() string</code> method to your struct.`)
}
if strings.Contains(errLower, "signal") && strings.Contains(errLower, "not found") {
hints = append(hints, `Use a signal type field (<code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">t.Int</code>, <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">*t.String</code>, etc.) and initialize with <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">t.NewInt(0)</code>.`)
}
if strings.Contains(errLower, "nil pointer") || strings.Contains(errLower, "invalid memory") {
hints = append(hints, `Initialize all pointer fields. Use <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">*t.Inject[T]</code> for DI dependencies.`)
}
if strings.Contains(errLower, "store") && strings.Contains(errLower, "not found") {
hints = append(hints, `Register store with <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">state.GlobalStoreManager.RegisterStore()</code>.`)
}
if strings.Contains(errLower, "index out of range") {
hints = append(hints, `Check bounds: <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">if len(items) > i { ... }</code>.`)
}
if strings.Contains(errLower, "dom") || strings.Contains(errLower, "element") {
hints = append(hints, `Ensure element exists before access. Check component mount order.`)
}
if len(hints) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString(`<div style="padding:0 24px 16px;"><div style="background:#eff6ff;border-radius:6px;padding:16px;">`)
sb.WriteString(`<div style="font-size:13px;font-weight:700;color:#1e40af;margin-bottom:8px;">How to fix this</div>`)
sb.WriteString(`<ul style="margin:0;padding-left:20px;font-size:13px;color:#374151;line-height:1.7;">`)
for _, h := range hints {
fmt.Fprintf(&sb, "<li>%s</li>", h)
}
sb.WriteString("</ul></div></div>")
return sb.String()
}
Fields
| Name | Type | Description |
|---|---|---|
| shown | bool | |
| container | js.Value | |
| errCount | int |
htmlEscape
Parameters
Returns
func htmlEscape(s string) string
{
s = strings.ReplaceAll(s, "&", "&")
s = strings.ReplaceAll(s, "<", "<")
s = strings.ReplaceAll(s, ">", ">")
s = strings.ReplaceAll(s, "\"", """)
return s
}
TestAddHostComponentKeepsAllNames
A component may be linked to several host components (one per host field on
a composition struct); registering a second name must not overwrite the
first, and duplicates collapse.
Parameters
func TestAddHostComponentKeepsAllNames(t *testing.T)
{
c := NewHTMLComponent("MultiHost", []byte(`<root></root>`), nil)
c.AddHostComponent("Counter")
c.AddHostComponent("Clock")
c.AddHostComponent("Counter")
names := c.hostComponentNames()
if len(names) != 2 || names[0] != "Counter" || names[1] != "Clock" {
t.Fatalf("unexpected host component names: %v", names)
}
if c.HostComponent != "Counter" {
t.Fatalf("primary host component overwritten: %s", c.HostComponent)
}
}
TestHostComponentFieldFallback
Directly assigning the exported HostComponent field keeps working.
Parameters
func TestHostComponentFieldFallback(t *testing.T)
{
c := NewHTMLComponent("FieldHost", []byte(`<root></root>`), nil)
c.HostComponent = "Legacy"
names := c.hostComponentNames()
if len(names) != 1 || names[0] != "Legacy" {
t.Fatalf("unexpected fallback names: %v", names)
}
}
Scope
Scope owns work that must stop with a component.
type Scope struct
Methods
Context is cancelled when the scope closes.
Returns
func (*Scope) Context() context.Context
{
s.mu.Lock()
defer s.mu.Unlock()
return s.ctx
}
Defer registers cleanup work in last-in, first-out order.
Parameters
func (*Scope) Defer(fn func())
{
if fn == nil {
return
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
fn()
return
}
s.cleanups = append(s.cleanups, fn)
s.mu.Unlock()
}
Go starts work with the scope context.
Parameters
func (*Scope) Go(fn func(context.Context))
{
if fn == nil {
return
}
go fn(s.Context())
}
Close cancels the context and runs registered cleanup once.
func (*Scope) Close()
{
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
cancel := s.cancel
cleanups := s.cleanups
s.cleanups = nil
s.mu.Unlock()
cancel()
for i := len(cleanups) - 1; i >= 0; i-- {
func(cleanup func()) {
defer func() {
if recovered := recover(); recovered != nil {
reportScopeError(recovered)
}
}()
cleanup()
}(cleanups[i])
}
}
Closed reports whether Close has run.
Returns
func (*Scope) Closed() bool
{
s.mu.Lock()
defer s.mu.Unlock()
return s.closed
}
Fields
| Name | Type | Description |
|---|---|---|
| mu | sync.Mutex | |
| ctx | context.Context | |
| cancel | context.CancelFunc | |
| cleanups | []func() | |
| closed | bool |
NewScope
NewScope creates an open lifecycle scope.
Returns
func NewScope() *Scope
{
ctx, cancel := context.WithCancel(context.Background())
return &Scope{ctx: ctx, cancel: cancel}
}
TestSuspenseMountState
Parameters
func TestSuspenseMountState(t *testing.T)
{
s := NewSuspense(func() (string, error) { return "ready", nil }, "loading")
if s.IsMounted() {
t.Fatal("new Suspense should not be mounted")
}
s.Mount()
if !s.IsMounted() {
t.Fatal("Suspense should be mounted after Mount")
}
s.Unmount()
if s.IsMounted() {
t.Fatal("Suspense should not be mounted after Unmount")
}
}
TestSuspenseUpdatesWhenResourceResolves
Parameters
func TestSuspenseUpdatesWhenResourceResolves(t *testing.T)
{
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
release := make(chan struct{})
resource := state.NewResource(func(context.Context) (string, error) {
<-release
return "ready", nil
})
defer resource.Close()
suspense := NewSuspense(func() (string, error) {
value, err := resource.Read()
return "<p>" + value + "</p>", err
}, "<p>loading</p>")
dom.UpdateDOM(suspense.GetID(), suspense.Render())
suspense.Mount()
defer suspense.Unmount()
if html := dom.ComponentRoot(suspense.GetID()).HTML(); !strings.Contains(html, "loading") {
t.Fatalf("fallback missing: %s", html)
}
close(release)
deadline := time.Now().Add(time.Second)
for {
if html := dom.ComponentRoot(suspense.GetID()).HTML(); strings.Contains(html, "ready") {
break
}
if time.Now().After(deadline) {
t.Fatalf("resolved content missing: %s", dom.ComponentRoot(suspense.GetID()).HTML())
}
time.Sleep(time.Millisecond)
}
}
TestSuspenseCoalescesReactiveDOMUpdates
Parameters
func TestSuspenseCoalescesReactiveDOMUpdates(t *testing.T)
{
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
value := state.NewSignal("zero")
suspense := NewSuspense(func() (string, error) {
return "<p>" + value.Get() + "</p>", nil
}, "<p>loading</p>")
dom.UpdateDOM(suspense.GetID(), suspense.Render())
suspense.Mount()
defer suspense.Unmount()
previousHook := dom.TemplateHook
commits := 0
dom.TemplateHook = func(componentID, _ string) {
if componentID == suspense.GetID() {
commits++
}
}
defer func() { dom.TemplateHook = previousHook }()
for i := 0; i < 10; i++ {
value.Set(fmt.Sprintf("value-%d", i))
}
waitForRenderFlush()
if commits != 1 {
t.Fatalf("Suspense update burst produced %d DOM commits, want 1", commits)
}
if html := dom.ComponentRoot(suspense.GetID()).HTML(); !strings.Contains(html, "value-9") {
t.Fatalf("Suspense committed stale content: %s", html)
}
}
type namedTestPlugin struct
Methods
Parameters
Returns
func (*namedTestPlugin) Build(json.RawMessage) error
{ return nil }
Fields
| Name | Type | Description |
|---|---|---|
| installed | int |
TestRegisterPlugin_dedup
Parameters
func TestRegisterPlugin_dedup(t *testing.T)
{
app = newApp()
p1 := &namedTestPlugin{}
RegisterPlugin(p1)
if p1.installed != 1 {
t.Fatalf("expected first plugin to install once, got %d", p1.installed)
}
p2 := &namedTestPlugin{}
RegisterPlugin(p2)
if p2.installed != 0 {
t.Fatalf("expected second plugin not to install, got %d", p2.installed)
}
if !app.HasPlugin("named-test") {
t.Fatalf("expected HasPlugin to return true")
}
}
type depPlugin struct
Methods
Fields
| Name | Type | Description |
|---|---|---|
| installed | int |
TestRegisterPlugin_requires
Parameters
func TestRegisterPlugin_requires(t *testing.T)
{
app = newApp()
dep := &depPlugin{}
req := &requiresPlugin{dep: dep}
RegisterPlugin(req)
if dep.installed != 1 {
t.Fatalf("expected dependency to install, got %d", dep.installed)
}
if !app.HasPlugin("dep") || !app.HasPlugin("requires") {
t.Fatalf("expected both plugins to be registered")
}
}
TestRegisterPlugin_optional
Parameters
func TestRegisterPlugin_optional(t *testing.T)
{
app = newApp()
dep := &depPlugin{}
opt := &optionalPlugin{dep: dep, enable: true}
RegisterPlugin(opt)
if dep.installed != 1 {
t.Fatalf("expected optional dependency to install")
}
app = newApp()
dep2 := &depPlugin{}
opt2 := &optionalPlugin{dep: dep2, enable: false}
RegisterPlugin(opt2)
if dep2.installed != 0 {
t.Fatalf("expected disabled optional dependency not to install")
}
}
HTMLComponent
HTMLComponent is the non-WASM component placeholder.
type HTMLComponent struct
Methods
Stats returns zeroed metrics on non-wasm builds.
Returns
func (*HTMLComponent) Stats() ComponentStats
{ return ComponentStats{} }
requestRender invalidates a mounted component and coalesces its DOM work with every other request made in the same JavaScript turn. Rendering is delayed, not state capture: the flush always observes the latest store and signal data.
func (*HTMLComponent) requestRender()
{
if c == nil || !c.mounted {
return
}
c.Invalidate()
requestScheduledRender(renderJob{
id: c.ID,
depth: componentDepth(c),
active: func() bool {
return c.mounted
},
render: func() {
dom.UpdateMountedDOM(c.ID, c.RenderFresh())
},
})
}
Render returns no markup outside WASM.
Returns
func (*HTMLComponent) Render() string
{ return "" }
GetName returns the component name.
Returns
func (*HTMLComponent) GetName() string
{ return c.Name }
GetID returns the component ID.
Returns
func (*HTMLComponent) GetID() string
{ return c.ID }
SetSlots performs no work outside WASM.
Parameters
func (*HTMLComponent) SetSlots(map[string]any)
{}
Scope returns the component lifecycle scope.
Returns
func (*HTMLComponent) Scope() *Scope
{
if c.scope == nil || c.scope.Closed() {
c.scope = NewScope()
}
return c.scope
}
Init attaches a state store and prepares the component template.
Parameters
func (*HTMLComponent) Init(store *state.Store)
{
if c.Store != nil {
return
}
template, err := LoadComponentTemplate(c.TemplateFS)
if err != nil {
panic(fmt.Sprintf("Error loading template for component %s: %v", c.Name, err))
}
template = devOverrideTemplate(c, template)
c.Template = template
dom.RegisterBindings(c.ID, c.Name, template)
devRegisterComponent(c)
if store != nil {
c.Store = store
} else {
c.Store = state.GlobalStoreManager.GetStore("app", "default")
if c.Store == nil {
c.Store = state.NewStore("default", state.WithModule("app"))
}
}
}
RenderFresh clears the render cache and re-renders. Reactive updates (store OnChange, signal effects) call this so a state change always produces up-to-date HTML instead of a stale cached render. Fixes the bug where a store.Set did not re-render @for / @expr / store-bound templates because the cache key hashes only Props/Dependencies, not the bound store state.
Returns
func (*HTMLComponent) RenderFresh() string
{
c.Invalidate()
return c.Render()
}
Invalidate drops the render cache of this component and of everything it includes. The cache key covers props and dependency identity, never the store state a template binds to, so an included component handed back its first render forever: a dependency whose markup depends on a shared store key (an @if on a global flag) froze at the value it had when the parent first painted.
func (*HTMLComponent) Invalidate()
{
c.cache = nil
c.lastCacheKey = ""
for _, dep := range c.Dependencies {
if d, ok := dep.(interface{ Invalidate() }); ok {
d.Invalidate()
}
}
}
Render evaluates the component template.
Returns
func (*HTMLComponent) Render() (renderedTemplate string)
{
start := time.Now()
defer func() { c.recordRender(time.Since(start)) }()
key := c.cacheKey()
if c.cache != nil {
if val, ok := c.cache[key]; ok {
renderedTemplate = val
return
}
if c.lastCacheKey != "" && c.lastCacheKey != key {
delete(c.cache, c.lastCacheKey)
}
} else {
c.cache = make(map[string]string)
}
defer func() {
if r := recover(); r != nil {
ReportError(r, fmt.Sprintf("Render: %s (ID: %s)", c.Name, c.ID))
renderedTemplate = ""
}
}()
c.unsubscribes.Run()
renderedTemplate = c.Template
renderedTemplate = strings.Replace(renderedTemplate, "<root", fmt.Sprintf("<root data-component-id=\"%s\"", c.ID), 1)
// Extract slot contents destined for child components
renderedTemplate = extractSlotContents(renderedTemplate, c)
// Replace this component's slot placeholders with provided content or fallbacks
renderedTemplate = replaceSlotPlaceholders(renderedTemplate, c)
// {{prop}} substitutions are HTML-escaped like @prop; @rawprop remains the
// explicit escape hatch for trusted markup.
for key, value := range c.Props {
placeholder := fmt.Sprintf("{{%s}}", key)
renderedTemplate = strings.ReplaceAll(renderedTemplate, placeholder, escapeValue(value))
}
// Register @include directives that supply inline props
renderedTemplate = replaceComponentIncludes(renderedTemplate, c)
// Handle @include:componentName syntax for dependencies
renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)
// Handle @for loops
renderedTemplate = replaceForPlaceholders(renderedTemplate, c)
renderedTemplate = replaceStorePlaceholders(renderedTemplate, c)
renderedTemplate = replaceSignalPlaceholders(renderedTemplate, c)
renderedTemplate = replaceExprInClassAttr(renderedTemplate, c)
renderedTemplate = replaceExprPlaceholders(renderedTemplate, c)
// Handle @prop:propName syntax for props
renderedTemplate = replacePropPlaceholders(renderedTemplate, c)
// Handle plugin variable and command placeholders
renderedTemplate = replacePluginPlaceholders(renderedTemplate)
// Handle host variable and command placeholders
if len(c.hostComponentNames()) > 0 {
renderedTemplate = replaceHostPlaceholders(renderedTemplate, c)
}
// Handle @if:condition syntax for conditional rendering
renderedTemplate = replaceConditionals(renderedTemplate, c)
// Handle @on:event:handler and @event:handler syntax for event binding
renderedTemplate = replaceEventHandlers(renderedTemplate)
// Handle rt-is="ComponentName" for dynamic component loading
renderedTemplate = replaceRtIsAttributes(renderedTemplate, c)
// Render any components introduced via rt-is placeholders
renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)
// Handle constructor decorators like [ref] and [key expr]
renderedTemplate = replaceConstructors(renderedTemplate)
for _, name := range c.hostComponentNames() {
hostclient.RegisterComponent(c.ID, name, c.hostVars)
}
renderedTemplate = minifyInline(renderedTemplate)
c.cache[key] = renderedTemplate
c.lastCacheKey = key
return renderedTemplate
}
Parameters
func (*HTMLComponent) recordRender(duration time.Duration)
{
if c == nil {
return
}
c.metricsMu.Lock()
c.renderCount++
c.totalRender += duration
c.lastRender = duration
c.appendTimelineLocked(ComponentTimelineEntry{
Kind: "render",
Timestamp: time.Now(),
Duration: duration,
})
c.metricsMu.Unlock()
}
Parameters
func (*HTMLComponent) appendTimelineLocked(entry ComponentTimelineEntry)
{
if entry.Kind == "" {
return
}
if c.timeline == nil {
c.timeline = make([]ComponentTimelineEntry, 0, 8)
}
c.timeline = append(c.timeline, entry)
if len(c.timeline) > componentTimelineLimit {
c.timeline = append([]ComponentTimelineEntry(nil), c.timeline[len(c.timeline)-componentTimelineLimit:]...)
}
}
Stats returns a snapshot of the component's render metrics.
Returns
func (*HTMLComponent) Stats() ComponentStats
{
c.metricsMu.Lock()
defer c.metricsMu.Unlock()
stats := ComponentStats{
RenderCount: c.renderCount,
TotalRender: c.totalRender,
LastRender: c.lastRender,
}
if c.renderCount > 0 {
stats.AverageRender = c.totalRender / time.Duration(c.renderCount)
}
if len(c.timeline) > 0 {
stats.Timeline = append(stats.Timeline, c.timeline...)
}
return stats
}
AddDependency attaches a child component to a template placeholder.
Parameters
func (*HTMLComponent) AddDependency(placeholderName string, dep Component)
{
if c.Dependencies == nil {
c.Dependencies = make(map[string]Component)
}
if depComp, ok := dep.(*HTMLComponent); ok {
depComp.Init(c.Store)
depComp.parent = c
}
c.Dependencies[placeholderName] = dep
}
Unmount releases component resources and child dependencies.
func (*HTMLComponent) Unmount()
{
// The idempotence guard keeps finalizers from repeating lifecycle cleanup.
if !c.mounted {
return
}
c.mounted = false
cancelComponentRender(c.ID)
devUnregisterComponent(c)
if c.component != nil {
c.runLifecycle("OnUnmount", c.component.OnUnmount)
}
dom.UnmountLifecycleHooks(c.ID)
c.releaseDOMHooks()
if c.scope != nil {
c.scope.Close()
}
dom.RemoveComponentSignals(c.ID)
dom.ReleaseInputBindings(c.ID)
dom.ReleaseComponentHandlers(c.ID)
root := dom.ComponentRoot(c.ID)
if !root.IsNull() && !root.IsUndefined() {
dom.RemoveDelegatedEvents(c.ID, root.Value)
}
log.Printf("Unsubscribing %s from all stores", c.Name)
c.unsubscribes.Run()
for _, dep := range c.Dependencies {
dependency := dep
c.runLifecycle("dependency unmount", dependency.Unmount)
}
}
Mount activates the component and its child dependencies.
func (*HTMLComponent) Mount()
{
c.mounted = true
if c.scope == nil || c.scope.Closed() {
c.scope = NewScope()
}
c.registerHandlers()
c.registerDOMHooks()
for _, dep := range c.Dependencies {
dependency := dep
c.runLifecycle("dependency mount", dependency.Mount)
}
root := dom.ComponentRoot(c.ID)
if !root.IsNull() && !root.IsUndefined() {
dom.DelegateEvents(c.ID, root.Value)
}
if c.component != nil {
c.runLifecycle("OnMount", c.component.OnMount)
}
dom.MountLifecycleHooks(c.ID)
}
Parameters
func (*HTMLComponent) runLifecycle(phase string, fn func())
{
defer func() {
if recovered := recover(); recovered != nil {
ReportError(recovered, phase+": "+c.Name+" (ID: "+c.ID+")")
}
}()
fn()
}
Scope returns the lifecycle scope owned by this component.
Returns
func (*HTMLComponent) Scope() *Scope
{
if c.scope == nil || c.scope.Closed() {
c.scope = NewScope()
}
return c.scope
}
Effect registers a reactive effect that stops on unmount.
Parameters
func (*HTMLComponent) Effect(fn func() func())
{
c.Scope().Defer(state.Effect(fn))
}
DOMHook registers root lifecycle callbacks owned by this component.
Parameters
func (*HTMLComponent) DOMHook(hook dom.LifecycleHook)
{
c.domHooks = append(c.domHooks, hook)
if c.mounted {
c.domHookStops = append(c.domHookStops, dom.RegisterLifecycleHook(c.ID, hook))
dom.MountLifecycleHooks(c.ID)
}
}
func (*HTMLComponent) registerDOMHooks()
{
c.releaseDOMHooks()
for _, hook := range c.domHooks {
c.domHookStops = append(c.domHookStops, dom.RegisterLifecycleHook(c.ID, hook))
}
}
func (*HTMLComponent) releaseDOMHooks()
{
for _, stop := range c.domHookStops {
stop()
}
c.domHookStops = nil
}
On registers an event handler owned by this component instance.
Parameters
func (*HTMLComponent) On(name string, fn func())
{
if name == "" {
panic("core.HTMLComponent.On: empty handler name")
}
if fn == nil {
panic("core.HTMLComponent.On: nil fn")
}
c.handlers[name] = fn
dom.RegisterComponentHandlerFunc(c.ID, name, fn)
}
func (*HTMLComponent) registerHandlers()
{
for name, fn := range c.handlers {
dom.RegisterComponentHandlerFunc(c.ID, name, fn)
}
}
GetName returns the component name.
Returns
func (*HTMLComponent) GetName() string
{
return c.Name
}
GetID returns the component identifier.
Returns
func (*HTMLComponent) GetID() string
{
return c.ID
}
GetRef returns the DOM element annotated with a matching constructor decorator. It searches within this component's root element using the data-ref attribute injected during template rendering.
Parameters
Returns
func (*HTMLComponent) GetRef(name string) dom.Element
{
root := dom.ComponentRoot(c.ID)
if root.IsNull() || root.IsUndefined() {
return dom.Element{}
}
return root.Query(fmt.Sprintf(`[data-ref="%s"]`, name))
}
OnMount runs the configured mount callback.
func (*HTMLComponent) OnMount()
{
if c.onMount != nil {
c.onMount(c)
}
}
OnUnmount runs the configured unmount callback.
func (*HTMLComponent) OnUnmount()
{
if c.onUnmount != nil {
c.onUnmount(c)
}
c.mounted = false
}
IsMounted reports whether the component is mounted.
Returns
func (*HTMLComponent) IsMounted() bool
{
return c.mounted
}
OnParams runs the configured route-parameter callback.
Parameters
func (*HTMLComponent) OnParams(params map[string]string)
{
if c.onParams != nil {
c.onParams(c, params)
}
}
SetOnParams configures the route-parameter callback.
Parameters
func (*HTMLComponent) SetOnParams(fn func(*HTMLComponent, map[string]string))
{
c.onParams = fn
}
SetOnMount configures the mount callback.
Parameters
func (*HTMLComponent) SetOnMount(fn func(*HTMLComponent))
{
c.onMount = fn
}
SetOnUnmount configures the unmount callback.
Parameters
func (*HTMLComponent) SetOnUnmount(fn func(*HTMLComponent))
{
c.onUnmount = fn
}
WithLifecycle configures mount and unmount callbacks.
Parameters
Returns
func (*HTMLComponent) WithLifecycle(onMount, onUnmount func(*HTMLComponent)) *HTMLComponent
{
c.onMount = onMount
c.onUnmount = onUnmount
return c
}
SetComponent attaches the component lifecycle implementation.
Parameters
func (*HTMLComponent) SetComponent(component Component)
{
c.component = component
}
SetSlots merges named slot content into the component.
Parameters
func (*HTMLComponent) SetSlots(slots map[string]any)
{
if c.Slots == nil {
c.Slots = make(map[string]any)
}
for k, v := range slots {
c.Slots[k] = v
}
}
Provide stores a value on this component so that descendants can retrieve it with Inject. It creates the map on first use.
Parameters
func (*HTMLComponent) Provide(key string, val any)
{
if c.provides == nil {
c.provides = make(map[string]any)
}
c.provides[key] = val
}
Inject searches for a provided value starting from this component and walking up the parent chain. It returns the value as `any` and whether it was found. Callers can type-assert the result.
Parameters
Returns
func (*HTMLComponent) Inject(key string) (any, bool)
{
if c.provides != nil {
if v, ok := c.provides[key]; ok {
return v, true
}
}
if c.parent != nil {
return c.parent.Inject(key)
}
return nil, false
}
SetRouteParams merges route parameters into component props.
Parameters
func (*HTMLComponent) SetRouteParams(params map[string]string)
{
if c.Props == nil {
c.Props = make(map[string]any)
}
for k, v := range params {
c.Props[k] = v
}
}
AddHostComponent links this HTML component to a server-side HostComponent by name. When running in SSC mode, messages from the wasm runtime will be routed to the corresponding host component on the server. It may be called multiple times (e.g. a composition struct with several host fields): every name is registered, and HostComponent keeps the first one as the primary.
Parameters
func (*HTMLComponent) AddHostComponent(name string)
{
for _, n := range c.hostComponents {
if n == name {
return
}
}
c.hostComponents = append(c.hostComponents, name)
if c.HostComponent == "" {
c.HostComponent = name
}
}
hostComponentNames returns every host component linked to this component, including a HostComponent assigned directly to the exported field.
Returns
func (*HTMLComponent) hostComponentNames() []string
{
if len(c.hostComponents) > 0 {
return c.hostComponents
}
if c.HostComponent != "" {
return []string{c.HostComponent}
}
return nil
}
Returns
func (*HTMLComponent) cacheKey() string
{
hasher := sha256.New()
hasher.Write([]byte(serializeProps(c.Props)))
if len(c.Dependencies) > 0 {
deps := make([]string, 0, len(c.Dependencies))
for name, dep := range c.Dependencies {
deps = append(deps, name+dep.GetID())
}
sort.Strings(deps)
for _, d := range deps {
hasher.Write([]byte(d))
}
}
return hex.EncodeToString(hasher.Sum(nil)[:20])
}
Fields
| Name | Type | Description |
|---|---|---|
| ID | string | |
| Name | string | |
| scope | *Scope |
ErrorSink
ErrorSink receives a recovered error together with a short human-readable
context such as “Render: Home (ID: abc)”.
type ErrorSink func(err any, context string)
OnError
OnError registers a sink invoked for every error reported by the runtime.
It returns a function that removes the sink.
Parameters
Returns
func OnError(fn ErrorSink) func()
{
if fn == nil {
return func() {}
}
errorMu.Lock()
errorSinks = append(errorSinks, fn)
idx := len(errorSinks) - 1
errorMu.Unlock()
return func() {
errorMu.Lock()
if idx < len(errorSinks) {
errorSinks[idx] = nil
}
errorMu.Unlock()
}
}
Uses
ReportError
ReportError delivers err to every registered sink and to the developer
overlay. All recovery paths in the framework funnel through here.
Parameters
func ReportError(err any, context string)
{
errorMu.Lock()
sinks := make([]ErrorSink, len(errorSinks))
copy(sinks, errorSinks)
errorMu.Unlock()
for _, fn := range sinks {
if fn != nil {
func() {
defer func() {
if sinkPanic := recover(); sinkPanic != nil {
log.Printf("[rfw] error sink failed while reporting %s: %v", context, sinkPanic)
}
}()
fn(err, context)
}()
}
}
func() {
defer func() {
if overlayPanic := recover(); overlayPanic != nil {
log.Printf("[rfw] error overlay failed while reporting %s: %v", context, overlayPanic)
}
}()
ShowErrorOverlay(err, context)
}()
}
init
Delegated event handlers recover panics inside the dom package; route them
into the same pipeline as every other capture point.
func init()
{
dom.OnHandlerPanic = func(err any, name string) {
ReportError(err, "Handler: "+name)
}
js.OnRuntimePanic = func(err any, context string, stack []byte) {
ReportError(recoveredPanic{value: err, stack: stack}, context)
}
state.OnCallbackPanic = func(err any, context string, stack []byte) {
ReportError(recoveredPanic{value: err, stack: stack}, context)
}
}
recoveredPanic
type recoveredPanic struct
Methods
Fields
| Name | Type | Description |
|---|---|---|
| value | any | |
| stack | []byte |
errPipeComponent
type errPipeComponent struct
Methods
newErrPipeComponent
Returns
func newErrPipeComponent() *errPipeComponent
{
c := &errPipeComponent{HTMLComponent: NewHTMLComponent("Boom", []byte("<root><div></div></root>"), nil)}
c.SetComponent(c)
c.Init(nil)
return c
}
TestReportErrorFansOutToSinks
Parameters
func TestReportErrorFansOutToSinks(t *testing.T)
{
var got []string
stop := OnError(func(_ any, ctx string) {
got = append(got, ctx)
})
defer stop()
TryRender(newErrPipeComponent())
if len(got) != 1 || !strings.HasPrefix(got[0], "Render: Boom") {
t.Fatalf("expected render report, got %v", got)
}
b := NewErrorBoundary(newErrPipeComponent(), "<p>fallback</p>")
out := b.Render()
if !strings.Contains(out, "fallback") {
t.Fatalf("expected fallback html, got %q", out)
}
if len(got) != 2 || !strings.HasPrefix(got[1], "Boundary render: Boom") {
t.Fatalf("expected boundary report, got %v", got)
}
stop()
TryRender(newErrPipeComponent())
if len(got) != 2 {
t.Fatalf("sink should be removed, got %v", got)
}
}
resolveNestedKey
Parameters
Returns
func resolveNestedKey(m map[string]any, key string) (any, bool)
{
parts := strings.Split(key, ".")
val := any(m)
for _, part := range parts {
sub, ok := val.(map[string]any)
if !ok {
return nil, false
}
val, ok = sub[part]
if !ok {
return nil, false
}
}
return val, true
}
replaceForPlaceholders
Parameters
Returns
func replaceForPlaceholders(template string, c *HTMLComponent) string
{
forRegex := regexp.MustCompile(`@for:(\w+(?:,\w+)?)\s+in\s+(\S+)([\s\S]*?)@endfor`)
// loop ids are positional, so a re-render hands every loop the id its rows
// already carry in the DOM
c.forSeq = 0
return forRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := forRegex.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
varsPart := parts[1]
expr := parts[2]
loopContent := parts[3]
aliases := strings.Split(varsPart, ",")
for i := range aliases {
aliases[i] = strings.TrimSpace(aliases[i])
}
loopID := fmt.Sprintf("for-%s-%d", c.ID, c.forSeq)
c.forSeq++
if strings.Contains(expr, "..") {
rangeParts := strings.Split(expr, "..")
if len(rangeParts) != 2 {
return match
}
start, err := resolveNumber(rangeParts[0], c)
if err != nil {
return match
}
end, err := resolveNumber(rangeParts[1], c)
if err != nil {
return match
}
var result strings.Builder
for i := start; i <= end; i++ {
iter := strings.ReplaceAll(loopContent, fmt.Sprintf("@prop:%s", aliases[0]), fmt.Sprintf("%d", i))
iter = insertDataKey(iter, i)
result.WriteString(iter)
}
return result.String()
}
var collection any
if strings.HasPrefix(expr, "store:") {
storeParts := strings.Split(strings.TrimPrefix(expr, "store:"), ".")
if len(storeParts) == 3 {
module, storeName, key := storeParts[0], storeParts[1], storeParts[2]
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
collection = store.Get(key)
// Rendering is coalesced per component; the common DOM
// reconciler then limits mutation to changed loop rows.
unsubscribe := store.OnChange(key, func(any) {
if loopInHiddenConditionalBranch(c, loopID) {
return
}
c.requestRender()
})
c.unsubscribes.Add(unsubscribe)
} else {
return match
}
} else {
return match
}
} else if val, ok := c.Props[expr]; ok {
collection = val
} else {
return match
}
switch col := collection.(type) {
case []Component:
tmp := make([]any, len(col))
for i, v := range col {
tmp[i] = v
}
collection = tmp
case []*HTMLComponent:
tmp := make([]any, len(col))
for i, v := range col {
tmp[i] = v
}
collection = tmp
case map[string]Component:
tmp := make(map[string]any, len(col))
for k, v := range col {
tmp[k] = v
}
collection = tmp
case map[string]*HTMLComponent:
tmp := make(map[string]any, len(col))
for k, v := range col {
tmp[k] = v
}
collection = tmp
}
rows, ok := expandForRows(c, aliases, loopContent, collection, loopID)
if !ok {
return match
}
return forAnchor(loopID) + rows
})
}
loopInHiddenConditionalBranch
loopInHiddenConditionalBranch prevents data arriving for an unmounted view
from repainting the visible component. The condition’s own subscription will
request a render when that branch becomes visible, using the latest data.
Parameters
Returns
func loopInHiddenConditionalBranch(c *HTMLComponent, loopID string) bool
{
if c == nil || !c.mounted {
return true
}
marker := fmt.Sprintf(`data-for-anchor="%s"`, loopID)
for conditionID, content := range c.conditionContents {
containsLoop := false
for _, branch := range content.Branches {
if strings.Contains(branch.Content, marker) {
containsLoop = true
break
}
}
if !containsLoop {
continue
}
root := dom.ComponentRoot(c.ID)
if root.IsNull() || root.IsUndefined() {
return true
}
condition := root.Query(fmt.Sprintf(`[data-condition="%s"]`, conditionID))
if condition.IsNull() || condition.IsUndefined() {
return true
}
selected := condition.Attr("data-condition-branch")
for index, branch := range content.Branches {
if fmt.Sprintf("%d", index) == selected {
return !strings.Contains(branch.Content, marker)
}
}
return true
}
return false
}
forAnchor
forAnchor marks where a loop’s rows begin. A template element carries no box
and no layout, so it sits inside a flex or grid container without disturbing
it, and an empty list still leaves the patch somewhere to insert into.
Parameters
Returns
func forAnchor(loopID string) string
{
return fmt.Sprintf(`<template data-for-anchor="%s"></template>`, loopID)
}
insertRowMarkers
insertRowMarkers stamps the row key and the loop id on the row’s opening tag,
so a patch finds exactly the nodes the loop owns without touching the markup
inside them.
Parameters
Returns
func insertRowMarkers(content string, key any, loopID string) string
{
loc := reTagName.FindStringSubmatchIndex(content)
if loc == nil {
return content
}
attrs := ""
openEnd := strings.IndexByte(content, '>')
if openEnd < 0 || (!strings.Contains(content[:openEnd], "[key ") && !strings.Contains(content[:openEnd], "data-key=")) {
attrs = fmt.Sprintf(` data-key="%v"`, key)
}
if loopID != "" {
attrs += fmt.Sprintf(` data-for="%s"`, loopID)
}
return content[:loc[1]] + attrs + content[loc[1]:]
}
expandForRows
expandForRows renders the loop body once per item. It reports false when the
collection is not a shape the loop understands.
Parameters
Returns
func expandForRows(c *HTMLComponent, aliases []string, loopContent string, collection any, loopID string) (string, bool)
{
switch col := collection.(type) {
case nil:
// unset store key: no rows, and the anchor keeps the spot so the first
// value that lands can be patched in
return "", true
case []any:
var result strings.Builder
alias := aliases[0]
for idx, item := range col {
iterContent := loopContent
if comp, ok := item.(Component); ok {
placeholder := fmt.Sprintf("for-%s-%d", alias, idx)
c.AddDependency(placeholder, comp)
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", alias), fmt.Sprintf("@include:%s", placeholder))
} else if itemMap, ok := item.(map[string]any); ok {
iterContent = substituteItemFields(iterContent, alias, itemMap)
} else {
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", alias), escapeValue(item))
}
iterContent = insertRowMarkers(iterContent, idx, loopID)
result.WriteString(iterContent)
}
return result.String(), true
case map[string]any:
keyAlias := aliases[0]
valAlias := keyAlias
if len(aliases) > 1 {
valAlias = aliases[1]
}
keys := make([]string, 0, len(col))
for k := range col {
keys = append(keys, k)
}
sort.Strings(keys)
var result strings.Builder
for idx, k := range keys {
v := col[k]
iterContent := strings.ReplaceAll(loopContent, fmt.Sprintf("@prop:%s", keyAlias), escapeValue(k))
if len(aliases) > 1 {
if vMap, ok := v.(map[string]any); ok {
iterContent = substituteItemFields(iterContent, valAlias, vMap)
} else if comp, ok := v.(Component); ok {
placeholder := fmt.Sprintf("for-%s-%d", valAlias, idx)
c.AddDependency(placeholder, comp)
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", valAlias), fmt.Sprintf("@include:%s", placeholder))
} else {
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@rawprop:%s", valAlias), fmt.Sprintf("%v", v))
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", valAlias), escapeValue(v))
}
}
iterContent = insertRowMarkers(iterContent, k, loopID)
result.WriteString(iterContent)
}
return result.String(), true
default:
return "", false
}
}
substituteItemFields
substituteItemFields fills the @prop / @rawprop field references of one item.
Parameters
Returns
func substituteItemFields(content, alias string, item map[string]any) string
{
rawRegex := regexp.MustCompile(fmt.Sprintf(`@rawprop:%s\.(\w+(?:\.\w+)*)`, alias))
content = rawRegex.ReplaceAllStringFunc(content, func(fieldMatch string) string {
fieldParts := rawRegex.FindStringSubmatch(fieldMatch)
if len(fieldParts) == 2 {
if fieldValue, ok := resolveNestedKey(item, fieldParts[1]); ok {
return fmt.Sprintf("%v", fieldValue)
}
}
return fieldMatch
})
fieldRegex := regexp.MustCompile(fmt.Sprintf(`@prop:%s\.(\w+(?:\.\w+)*)`, alias))
return fieldRegex.ReplaceAllStringFunc(content, func(fieldMatch string) string {
fieldParts := fieldRegex.FindStringSubmatch(fieldMatch)
if len(fieldParts) == 2 {
if fieldValue, ok := resolveNestedKey(item, fieldParts[1]); ok {
return escapeValue(fieldValue)
}
}
return fieldMatch
})
}
mountForComponent
Parameters
Returns
func mountForComponent(t *testing.T, name string, tpl []byte) *HTMLComponent
{
t.Helper()
host := dom.Doc().CreateElement("div")
dom.Doc().Body().AppendChild(host)
t.Cleanup(func() { host.Call("remove") })
c := NewHTMLComponent(name, tpl, nil)
c.SetComponent(c)
c.Init(nil)
dom.UpdateDOMIn(host, c.ID, c.Render())
c.Mount()
return c
}
TestForPatchLeavesSiblingsAlone
A list that changes should cost its own rows, not a re-render of everything
around it: the sibling markup keeps its node identity.
Parameters
func TestForPatchLeavesSiblingsAlone(t *testing.T)
{
st := state.NewStore("forpatch", state.WithModule("app"))
st.Set("items", []any{
map[string]any{"label": "one"},
map[string]any{"label": "two"},
})
waitForRenderFlush()
tpl := []byte(`<root><div id="forpatch-side">side</div><ul>@for:it in store:app.forpatch.items <li>@prop:it.label</li>@endfor</ul></root>`)
mountForComponent(t, "ForPatch", tpl)
side := dom.ByID("forpatch-side")
if side.IsNull() {
t.Fatal("sibling not rendered")
}
side.Set("__marker", "kept")
st.Set("items", []any{
map[string]any{"label": "one"},
map[string]any{"label": "two"},
map[string]any{"label": "three"},
})
waitForRenderFlush()
sideAfter := dom.ByID("forpatch-side")
if sideAfter.IsNull() {
t.Fatal("sibling vanished after the list changed")
}
if got := sideAfter.Get("__marker"); !got.Truthy() || got.String() != "kept" {
t.Fatal("sibling was re-created: the whole component re-rendered")
}
}
TestForPatchRendersEveryRow
The patched rows have to match what a full render would have produced.
Parameters
func TestForPatchRendersEveryRow(t *testing.T)
{
st := state.NewStore("forpatch2", state.WithModule("app"))
st.Set("items", []any{map[string]any{"label": "a"}})
tpl := []byte(`<root><ul data-list>@for:it in store:app.forpatch2.items <li>@prop:it.label</li>@endfor</ul></root>`)
mountForComponent(t, "ForPatch2", tpl)
st.Set("items", []any{
map[string]any{"label": "x"},
map[string]any{"label": "y"},
})
waitForRenderFlush()
list := dom.Query("[data-list]")
rows := list.QueryAll("li")
if rows.Length() != 2 {
t.Fatalf("expected 2 rows, got %d (%s)", rows.Length(), list.HTML())
}
if got := rows.Index(0).Text(); got != "x" {
t.Fatalf("first row = %q", got)
}
if got := rows.Index(1).Text(); got != "y" {
t.Fatalf("second row = %q", got)
}
// emptying the list clears the rows and keeps the anchor for the next value
st.Set("items", []any{})
waitForRenderFlush()
if n := dom.Query("[data-list]").QueryAll("li").Length(); n != 0 {
t.Fatalf("expected no rows after clearing, got %d", n)
}
st.Set("items", []any{map[string]any{"label": "back"}})
waitForRenderFlush()
if got := dom.Query("[data-list]").QueryAll("li").Length(); got != 1 {
t.Fatalf("expected the list to come back, got %d rows", got)
}
}
TestUnifiedReconcilerPreservesExplicitlyKeyedRows
Parameters
func TestUnifiedReconcilerPreservesExplicitlyKeyedRows(t *testing.T)
{
store := state.NewStore("reconcile_keyed", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "reconcile_keyed")
store.Set("items", []any{
map[string]any{"id": "a", "label": "one"},
map[string]any{"id": "b", "label": "two"},
})
component := mountForComponent(t, "ReconcileKeyed", []byte(`<root><ul data-list>
@for:item in store:app.reconcile_keyed.items
<li [key @prop:item.id]>@prop:item.label</li>
@endfor
</ul></root>`))
defer component.Unmount()
rows := dom.Query("[data-list]").QueryAll("li")
rowA, rowB := rows.Index(0), rows.Index(1)
if rowA.Attr("data-key") != "a" || rowB.Attr("data-key") != "b" {
t.Fatalf("explicit row keys were not rendered: %s", dom.Query("[data-list]").HTML())
}
store.Set("items", []any{
map[string]any{"id": "b", "label": "two updated"},
map[string]any{"id": "c", "label": "three"},
})
waitForRenderFlush()
rows = dom.Query("[data-list]").QueryAll("li")
if rows.Length() != 2 || !rows.Index(0).Equal(rowB.Value) {
t.Fatal("keyed row identity was not retained during reorder")
}
if rows.Index(0).Text() != "two updated" {
t.Fatalf("retained row was not patched: %s", rows.Index(0).Text())
}
if rowA.Get("isConnected").Bool() {
t.Fatal("removed keyed row remains connected")
}
}
TestUnifiedReconcilerKeepsFocusedInputOutsideLoop
Parameters
func TestUnifiedReconcilerKeepsFocusedInputOutsideLoop(t *testing.T)
{
store := state.NewStore("reconcile_focus", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "reconcile_focus")
store.Set("state", "rows")
store.Set("query", "premier ")
store.Set("items", []any{map[string]any{"id": "a", "label": "one"}})
component := mountForComponent(t, "ReconcileFocus", []byte(`<root>
@if:store:app.reconcile_focus.state == "rows"
<section><input id="reconcile-search" value="@store:app.reconcile_focus.query:w"><ul>
@for:item in store:app.reconcile_focus.items
<li [key @prop:item.id]>@prop:item.label</li>
@endfor
</ul></section>
@endif
</root>`))
defer component.Unmount()
input := dom.ByID("reconcile-search")
input.Call("focus")
input.Call("setSelectionRange", 4, 8)
store.Set("state", "rows")
store.Set("items", []any{map[string]any{"id": "a", "label": "updated"}})
waitForRenderFlush()
if !dom.ByID("reconcile-search").Equal(input.Value) || !dom.Doc().Get("activeElement").Equal(input.Value) {
t.Fatal("same-branch list refresh replaced the focused input")
}
if input.Val() != "premier " || input.Get("selectionStart").Int() != 4 || input.Get("selectionEnd").Int() != 8 {
t.Fatalf("focused input state changed: value=%q selection=%d:%d", input.Val(), input.Get("selectionStart").Int(), input.Get("selectionEnd").Int())
}
}
TestHiddenLoopWaitsForBranchMount
Parameters
func TestHiddenLoopWaitsForBranchMount(t *testing.T)
{
store := state.NewStore("reconcile_hidden", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "reconcile_hidden")
store.Set("show", "no")
store.Set("items", []any{})
component := mountForComponent(t, "ReconcileHidden", []byte(`<root><input id="visible-control">
@if:store:app.reconcile_hidden.show == "yes"
<ul data-hidden-list>
@for:item in store:app.reconcile_hidden.items
<li [key @prop:item.id]>@prop:item.label</li>
@endfor
</ul>
@endif
</root>`))
defer component.Unmount()
before := component.Stats().RenderCount
visible := dom.ByID("visible-control")
store.Set("items", []any{map[string]any{"id": "a", "label": "latest"}})
waitForRenderFlush()
if component.Stats().RenderCount != before {
t.Fatal("hidden loop scheduled a visible component render")
}
if !dom.ByID("visible-control").Equal(visible.Value) {
t.Fatal("hidden loop update touched the mounted branch")
}
store.Set("show", "yes")
waitForRenderFlush()
rows := dom.Query("[data-hidden-list]").QueryAll("li")
if rows.Length() != 1 || rows.Index(0).Text() != "latest" {
t.Fatalf("mounted branch did not use latest loop state: %s", dom.Query("[data-hidden-list]").HTML())
}
}
TestForPatchRebindsInputsOnce
Parameters
func TestForPatchRebindsInputsOnce(t *testing.T)
{
st := state.NewStore("forpatch3", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "forpatch3")
st.Set("name", "")
st.Set("ast", "")
st.Set("row", "")
st.Set("items", []any{map[string]any{"label": "one"}})
tpl := []byte(`<root><input data-name value="@store:app.forpatch3.name:w"><input data-ast data-bind-store="app.forpatch3.ast"><ul>@for:it in store:app.forpatch3.items <li><input data-row data-bind-store="app.forpatch3.row">@prop:it.label</li>@endfor</ul></root>`)
c := mountForComponent(t, "ForPatch3", tpl)
defer c.Unmount()
st.Set("items", []any{map[string]any{"label": "two"}})
waitForRenderFlush()
oldRow := dom.Query("[data-row]")
st.Set("items", []any{})
waitForRenderFlush()
oldHook := state.StoreHook
defer func() { state.StoreHook = oldHook }()
sets := make(chan string, 4)
state.StoreHook = func(module, store, key string, value any) {
if module == "app" && store == "forpatch3" {
sets <- key
}
if oldHook != nil {
oldHook(module, store, key, value)
}
}
oldRow.Set("value", "detached")
oldRow.Call("dispatchEvent", js.CustomEvent().New("input"))
select {
case key := <-sets:
t.Fatalf("detached row updated store key %q", key)
case <-time.After(20 * time.Millisecond):
}
input := dom.Query("[data-name]")
input.Set("value", "Mirko")
input.Call("dispatchEvent", js.CustomEvent().New("input"))
expectOneStoreSet(t, sets, "name")
input = dom.Query("[data-ast]")
input.Set("value", "AST")
input.Call("dispatchEvent", js.CustomEvent().New("input"))
expectOneStoreSet(t, sets, "ast")
}
TestStoreInputDefersWritesDuringIMEComposition
Parameters
func TestStoreInputDefersWritesDuringIMEComposition(t *testing.T)
{
store := state.NewStore("ime_binding", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "ime_binding")
store.Set("value", "before")
component := mountForComponent(t, "IMEBinding", []byte(`<root><input data-ime value="@store:app.ime_binding.value:w"></root>`))
defer component.Unmount()
input := dom.Query("[data-ime]")
input.Set("value", "partial")
composingOptions := js.NewDict()
composingOptions.Set("bubbles", true)
composingOptions.Set("isComposing", true)
input.Call("dispatchEvent", js.Global().Get("InputEvent").New("input", composingOptions.Value))
time.Sleep(20 * time.Millisecond)
if got := store.Get("value"); got != "before" {
t.Fatalf("composing input wrote store value %q, want before", got)
}
input.Set("value", "final")
inputOptions := js.NewDict()
inputOptions.Set("bubbles", true)
input.Call("dispatchEvent", js.Global().Get("InputEvent").New("input", inputOptions.Value))
time.Sleep(20 * time.Millisecond)
if got := store.Get("value"); got != "final" {
t.Fatalf("completed input wrote store value %q, want final", got)
}
}
expectOneStoreSet
Parameters
func expectOneStoreSet(t *testing.T, sets <-chan string, want string)
{
t.Helper()
select {
case got := <-sets:
if got != want {
t.Fatalf("input updated store key %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatal("input did not update the store")
}
select {
case got := <-sets:
t.Fatalf("input updated store key %q more than once", got)
case <-time.After(20 * time.Millisecond):
}
}
TestForPatchRebindsSignalInputsOnce
Parameters
func TestForPatchRebindsSignalInputsOnce(t *testing.T)
{
st := state.NewStore("forpatch4", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "forpatch4")
st.Set("items", []any{map[string]any{"label": "one"}})
legacy := state.NewSignal("legacy")
ast := state.NewSignal("ast")
host := dom.Doc().CreateElement("div")
dom.Doc().Body().AppendChild(host)
t.Cleanup(func() { host.Call("remove") })
tpl := []byte(`<root><input data-legacy value="@signal:legacy:w"><span hidden>@signal:ast</span><input data-ast-signal data-bind-signal="ast"><ul>@for:it in store:app.forpatch4.items <li>@prop:it.label</li>@endfor</ul></root>`)
c := NewHTMLComponent("ForPatch4", tpl, map[string]any{
"legacy": legacy,
"ast": ast,
})
c.SetComponent(c)
c.Init(nil)
host.SetHTML(c.Render())
c.Mount()
defer c.Unmount()
st.Set("items", []any{map[string]any{"label": "two"}})
st.Set("items", []any{map[string]any{"label": "three"}})
waitForRenderFlush()
legacySets := make(chan string, 2)
legacySub := legacy.OnChange(func(value string) { legacySets <- value })
defer legacySub.Stop()
astSets := make(chan string, 2)
astSub := ast.OnChange(func(value string) { astSets <- value })
defer astSub.Stop()
input := dom.Query("[data-legacy]")
input.Set("value", "legacy-updated")
input.Call("dispatchEvent", js.CustomEvent().New("input"))
expectOneSignalSet(t, legacySets, "legacy-updated")
input = dom.Query("[data-ast-signal]")
input.Set("value", "ast-updated")
input.Call("dispatchEvent", js.CustomEvent().New("input"))
expectOneSignalSet(t, astSets, "ast-updated")
}
TestLoopInHiddenConditionalBranchIgnoresUnmountedComponent
Parameters
func TestLoopInHiddenConditionalBranchIgnoresUnmountedComponent(t *testing.T)
{
component := &HTMLComponent{
ID: "not-mounted",
conditionContents: map[string]ConditionContent{
"condition": {
Branches: []ConditionalBranchContent{{Content: `<template data-for-anchor="loop"></template>`}},
},
},
}
if !loopInHiddenConditionalBranch(component, "loop") {
t.Fatal("unmounted component should suppress loop render work")
}
if !loopInHiddenConditionalBranch(nil, "loop") {
t.Fatal("nil component should suppress loop render work")
}
app := dom.ByID("app")
if !app.IsNull() && !app.IsUndefined() {
parent := app.Get("parentNode")
next := app.Get("nextSibling")
app.Call("remove")
defer func() {
if next.Truthy() && next.Get("parentNode").Truthy() && next.Get("parentNode").Equal(parent) {
parent.Call("insertBefore", app.Value, next)
return
}
parent.Call("appendChild", app.Value)
}()
}
component.mounted = true
if !loopInHiddenConditionalBranch(component, "loop") {
t.Fatal("component without a DOM root should suppress loop render work")
}
}
expectOneSignalSet
Parameters
func expectOneSignalSet(t *testing.T, sets <-chan string, want string)
{
t.Helper()
select {
case got := <-sets:
if got != want {
t.Fatalf("signal value = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatal("input did not update the signal")
}
select {
case <-sets:
t.Fatal("input updated the signal more than once")
case <-time.After(20 * time.Millisecond):
}
}
reportScopeError
Parameters
func reportScopeError(err any)
{
ReportError(err, "component scope cleanup")
}
ensureAppRoot
Returns
func ensureAppRoot() dom.Element
{
app := dom.ByID("app")
if app.IsNull() {
app = dom.CreateElement("div")
app.SetAttr("id", "app")
dom.Doc().Body().AppendChild(app)
}
app.SetHTML("")
return app
}
testHTMLComponent
Parameters
Returns
func testHTMLComponent(name, template string) *HTMLComponent
{
component := NewHTMLComponent(name, []byte(template), nil)
component.SetComponent(component)
component.Init(nil)
return component
}
TestPortalMountsOutsideComponentTree
Parameters
func TestPortalMountsOutsideComponentTree(t *testing.T)
{
ensureAppRoot()
target := dom.CreateElement("div")
target.SetAttr("id", "portal-test-target")
dom.Doc().Body().AppendChild(target)
defer target.Call("remove")
child := testHTMLComponent("PortalChild", `<root><p id="portal-content">content</p></root>`)
portal := NewPortal("#portal-test-target", child)
dom.UpdateDOM(portal.GetID(), portal.Render())
portal.Mount()
if target.Query("#portal-content").IsNull() {
t.Fatal("portal child did not mount in target")
}
if !child.IsMounted() {
t.Fatal("portal child was not mounted")
}
portal.Unmount()
if !target.Query("#portal-content").IsNull() || child.IsMounted() {
t.Fatal("portal child was not cleaned up")
}
}
TestKeepAlivePreservesDOMAndState
Parameters
func TestKeepAlivePreservesDOMAndState(t *testing.T)
{
app := ensureAppRoot()
child := testHTMLComponent("CachedChild", `<root><input id="cached-input" value="initial"></root>`)
keep := NewKeepAlive(child)
dom.UpdateDOM(keep.GetID(), keep.Render())
keep.Mount()
input := dom.ByID("cached-input")
input.SetValue("edited")
keep.Unmount()
app.SetHTML("<p>other route</p>")
dom.UpdateDOM(keep.GetID(), keep.Render())
keep.Mount()
if value := dom.ByID("cached-input").Val(); value != "edited" {
t.Fatalf("cached DOM state was lost: %q", value)
}
if !child.IsMounted() {
t.Fatal("cached child was unmounted")
}
keep.Dispose()
if child.IsMounted() || !dom.ByID("cached-input").IsNull() {
t.Fatal("disposed cache kept the child alive")
}
}
TestTransitionRunsEnterAndLeavePhases
Parameters
func TestTransitionRunsEnterAndLeavePhases(t *testing.T)
{
ensureAppRoot()
child := testHTMLComponent("TransitionChild", `<root><p>transition</p></root>`)
transition := NewTransition(child, TransitionConfig{Duration: 20 * time.Millisecond})
dom.UpdateDOM(transition.GetID(), transition.Render())
transition.Mount()
root := dom.ComponentRoot(transition.GetID())
if !root.HasClass("rfw-enter-from") || !root.HasClass("rfw-enter-active") {
t.Fatalf("enter phase classes missing: %s", root.Attr("class"))
}
transition.Unmount()
if !root.HasClass("rfw-leave-from") || !root.HasClass("rfw-leave-active") {
t.Fatalf("leave phase classes missing: %s", root.Attr("class"))
}
deadline := time.Now().Add(time.Second)
for {
html := dom.Doc().Body().HTML()
if !child.IsMounted() && !strings.Contains(html, `data-component-id="`+transition.GetID()+`"`) {
break
}
if time.Now().After(deadline) {
t.Fatalf("transition leave did not finish: mounted=%v html=%s", child.IsMounted(), html)
}
time.Sleep(time.Millisecond)
}
}
TestTransitionCanRemountDuringLeave
Parameters
func TestTransitionCanRemountDuringLeave(t *testing.T)
{
app := ensureAppRoot()
child := testHTMLComponent("TransitionReturnChild", `<root><p id="transition-return">return</p></root>`)
transition := NewTransition(child, TransitionConfig{Duration: time.Second})
dom.UpdateDOM(transition.GetID(), transition.Render())
transition.Mount()
transition.Unmount()
app.SetHTML(transition.Render())
transition.Mount()
if !child.IsMounted() || dom.ByID("transition-return").IsNull() {
t.Fatal("transition did not remount during leave")
}
if roots := dom.QueryAll(`[data-component-id="` + transition.GetID() + `"]`); roots.Length() != 1 {
t.Fatalf("transition left %d roots after remount", roots.Length())
}
transition.Dispose()
}
TestForRendersComponentList
Parameters
func TestForRendersComponentList(t *testing.T)
{
state.NewStore("default", state.WithModule("app"))
childTpl1 := []byte("<root><p>first</p></root>")
childTpl2 := []byte("<root><p>second</p></root>")
child1 := NewComponent("Child1", childTpl1, nil)
child2 := NewComponent("Child2", childTpl2, nil)
parentTpl := []byte("<root>@for:item in items @prop:item @endfor</root>")
parent := NewComponent("Parent", parentTpl, map[string]any{"items": []Component{child1, child2}})
html := parent.Render()
if !strings.Contains(html, "first") || !strings.Contains(html, "second") {
t.Fatalf("expected child components rendered: %s", html)
}
}
TestForRendersMapFields
Parameters
func TestForRendersMapFields(t *testing.T)
{
state.NewStore("default", state.WithModule("app"))
items := []any{
map[string]any{"name": "Mario", "age": 30},
map[string]any{"name": "Luigi", "age": 25},
}
parentTpl := []byte("<root>@for:item in items <p><b>Name:</b> @prop:item.name <b>Age:</b> @prop:item.age</p> @endfor</root>")
parent := NewComponent("Parent", parentTpl, map[string]any{"items": items})
html := parent.Render()
if !strings.Contains(html, "Mario") || !strings.Contains(html, "Luigi") {
t.Fatalf("expected names rendered: %s", html)
}
if strings.Contains(html, "@prop:item.name") || strings.Contains(html, "@prop:item.age") {
t.Fatalf("placeholders not replaced: %s", html)
}
}
TestSafeJavaScriptCallbackReportsAndRuntimeContinues
Parameters
func TestSafeJavaScriptCallbackReportsAndRuntimeContinues(t *testing.T)
{
contexts := []string{}
stop := OnError(func(_ any, context string) { contexts = append(contexts, context) })
defer stop()
calls := 0
callback := js.SafeFuncOf(func(js.Value, []js.Value) any {
calls++
if calls == 1 {
panic("callback failure")
}
return calls
})
defer callback.Release()
callback.Invoke()
result := callback.Invoke()
if calls != 2 || result.Int() != 2 {
t.Fatalf("callback did not survive: calls=%d result=%v", calls, result)
}
if len(contexts) != 1 || !strings.Contains(contexts[0], "JavaScript callback") {
t.Fatalf("unexpected recovery contexts: %v", contexts)
}
}
TestErrorSinkPanicDoesNotBlockOtherSinks
Parameters
func TestErrorSinkPanicDoesNotBlockOtherSinks(t *testing.T)
{
stopBroken := OnError(func(any, string) { panic("sink failure") })
defer stopBroken()
called := 0
stopHealthy := OnError(func(any, string) { called++ })
defer stopHealthy()
ReportError("original", "test")
if called != 1 {
t.Fatalf("healthy error sink calls = %d, want 1", called)
}
}
TestScopeCancelsAndCleansUpOnce
Parameters
func TestScopeCancelsAndCleansUpOnce(t *testing.T)
{
scope := NewScope()
var cleanups atomic.Int32
cancelled := make(chan struct{})
scope.Defer(func() { cleanups.Add(1) })
scope.Go(func(ctx context.Context) {
<-ctx.Done()
close(cancelled)
})
scope.Close()
scope.Close()
select {
case <-cancelled:
case <-time.After(time.Second):
t.Fatal("scope context was not cancelled")
}
if cleanups.Load() != 1 {
t.Fatalf("cleanup count = %d", cleanups.Load())
}
}
TestScopeRunsLateCleanupImmediately
Parameters
func TestScopeRunsLateCleanupImmediately(t *testing.T)
{
scope := NewScope()
scope.Close()
called := false
scope.Defer(func() { called = true })
if !called {
t.Fatal("late cleanup did not run")
}
}
TestScopeRunsRemainingCleanupAfterPanic
Parameters
func TestScopeRunsRemainingCleanupAfterPanic(t *testing.T)
{
scope := NewScope()
ran := false
scope.Defer(func() { ran = true })
scope.Defer(func() { panic("cleanup") })
scope.Close()
if !ran {
t.Fatal("cleanup after panic did not run")
}
}
TestRegisterComponentDuplicate
Parameters
func TestRegisterComponentDuplicate(t *testing.T)
{
// reset registry
ComponentRegistry = map[string]func() Component{}
if err := RegisterComponent("dup", func() Component { return noopComponent{} }); err != nil {
t.Fatalf("unexpected error registering component: %v", err)
}
if err := RegisterComponent("dup", func() Component { return noopComponent{} }); err == nil {
t.Fatalf("expected error on duplicate registration")
}
}
TestMustRegisterComponentPanic
Parameters
func TestMustRegisterComponentPanic(t *testing.T)
{
ComponentRegistry = map[string]func() Component{}
MustRegisterComponent("dup", func() Component { return noopComponent{} })
defer func() {
if r := recover(); r == nil {
t.Fatalf("expected panic on duplicate registration")
}
}()
MustRegisterComponent("dup", func() Component { return noopComponent{} })
}
TestErrorBoundaryRender
Parameters
func TestErrorBoundaryRender(t *testing.T)
{
eb := NewErrorBoundary(&panicComponent{}, "<div>fb</div>")
html := eb.Render()
expected := "<root data-component-id=\"panic\"><div>fb</div></root>"
if html != expected {
t.Fatalf("expected %s, got %s", expected, html)
}
eb.Mount()
if !eb.IsMounted() {
t.Fatal("boundary fallback should be mounted")
}
eb.Unmount()
if eb.IsMounted() {
t.Fatal("boundary should not be mounted after Unmount")
}
}
TestDependencyRenderFollowsStore
A dependency bound to a store must not be frozen by the parent’s render
cache: the cache key knows nothing about store state, so re-rendering the
parent has to re-render the included subtree too.
Parameters
func TestDependencyRenderFollowsStore(t *testing.T)
{
store := state.NewStore("depcache", state.WithModule("app"))
store.Set("chrome", "on")
defer state.GlobalStoreManager.UnregisterStore("app", "depcache")
child := NewHTMLComponent("CacheChild", []byte(`<root>
@if:store:app.depcache.chrome == "on"
<span id="child-block">visible</span>
@endif
</root>`), nil)
child.SetComponent(child)
child.Init(nil)
parent := NewHTMLComponent("CacheParent", []byte(`<root><div>@include:child</div></root>`), nil)
parent.SetComponent(parent)
parent.AddDependency("child", child)
parent.Init(nil)
if html := parent.Render(); !strings.Contains(html, "child-block") {
t.Fatalf("first render missed the true branch: %s", html)
}
store.Set("chrome", "off")
if html := parent.RenderFresh(); strings.Contains(html, "child-block") {
t.Fatalf("dependency kept its cached render after the store changed: %s", html)
}
}
Node
Node renders a parsed template node.
type Node interface
Methods
TextNode
TextNode contains literal template text.
type TextNode struct
Methods
Render returns the literal text.
Parameters
Returns
func (*TextNode) Render(*HTMLComponent) string
{ return t.Text }
Fields
| Name | Type | Description |
|---|---|---|
| Text | string |
ConditionalBranch
ConditionalBranch contains a conditional expression and its nodes.
type ConditionalBranch struct
Fields
| Name | Type | Description |
|---|---|---|
| Condition | string | |
| Nodes | []Node |
ConditionalNode
ConditionalNode renders the first matching branch.
type ConditionalNode struct
Methods
Render evaluates the conditional branches and renders the appropriate content.
Parameters
Returns
func (*ConditionalNode) Render(c *HTMLComponent) string
{
var conditions []string
for _, br := range cn.Branches {
conditions = append(conditions, br.Condition)
}
conditionHash := sha256.Sum256([]byte(strings.Join(conditions, "|")))
conditionID := fmt.Sprintf("cond-%x-%d", conditionHash[:20], c.condSeq)
c.condSeq++
var content ConditionContent
var chosen string
chosenBranch := -1
branchSelected := false
for branchIndex, br := range cn.Branches {
var sb strings.Builder
for _, n := range br.Nodes {
sb.WriteString(n.Render(c))
}
branchContent := sb.String()
content.Branches = append(content.Branches, ConditionalBranchContent{Condition: br.Condition, Content: branchContent})
if br.Condition != "" {
result, _ := evaluateCondition(br.Condition, c)
if !branchSelected && result {
chosen = branchContent
chosenBranch = branchIndex
branchSelected = true
}
} else if !branchSelected {
chosen = branchContent
chosenBranch = branchIndex
branchSelected = true
}
}
c.conditionContents[conditionID] = content
initialEffectRun := true
unsub := state.Effect(func() func() {
for _, br := range cn.Branches {
if br.Condition != "" {
evaluateCondition(br.Condition, c)
}
}
if initialEffectRun {
initialEffectRun = false
} else {
c.requestRender()
}
return nil
})
c.unsubscribes.Add(unsub)
// The effect above tracks signals only. A condition reading a store key
// has to subscribe to it as well, otherwise a component whose template
// carries no other binding on that store (an @if and nothing else) renders
// once and never reacts.
for _, br := range cn.Branches {
if br.Condition == "" {
continue
}
deps, _ := getConditionDependencies(br.Condition)
for _, dep := range deps {
if dep.module == "" || dep.storeName == "" || dep.key == "" {
continue
}
store := state.GlobalStoreManager.GetStore(dep.module, dep.storeName)
if store == nil {
continue
}
unsub := store.OnChange(dep.key, func(any) {
c.requestRender()
})
c.unsubscribes.Add(unsub)
}
}
return fmt.Sprintf(`<div data-condition="%s" data-condition-branch="%d">%s</div>`, conditionID, chosenBranch, chosen)
}
Fields
| Name | Type | Description |
|---|---|---|
| Branches | []ConditionalBranch |
ConditionalBranchContent
ConditionalBranchContent stores rendered content for one branch.
type ConditionalBranchContent struct
Fields
| Name | Type | Description |
|---|---|---|
| Condition | string | |
| Content | string |
ConditionContent
ConditionContent stores all rendered branches of a conditional block.
type ConditionContent struct
Fields
| Name | Type | Description |
|---|---|---|
| Branches | []ConditionalBranchContent |
ConditionDependency
ConditionDependency identifies reactive state read by a condition.
type ConditionDependency struct
Fields
| Name | Type | Description |
|---|---|---|
| module | string | |
| storeName | string | |
| key | string | |
| signal | string |
replaceIncludePlaceholders
Parameters
Returns
func replaceIncludePlaceholders(c *HTMLComponent, renderedTemplate string) string
{
includeRegex := reInclude
return includeRegex.ReplaceAllStringFunc(renderedTemplate, func(match string) string {
name := includeRegex.FindStringSubmatch(match)[1]
if dep, ok := c.Dependencies[name]; ok {
return dep.Render()
}
if DevMode {
Log().Warn("component %s missing dependency '%s'", c.Name, name)
}
return match
})
}
replaceComponentIncludes
replaceComponentIncludes scans for @include directives that supply inline
props using the syntax @include:Component:{key:“value”}. Matching includes
are replaced with standard @include placeholders after instantiating the
component and registering it as a dependency.
Parameters
Returns
func replaceComponentIncludes(template string, c *HTMLComponent) string
{
idx := 0
// Handle includes that may be wrapped in <p> tags produced by Markdown
// renderers as well as bare @include directives.
patterns := []string{
`<p>@include:([\w-]+):\{([^}]*)\}</p>`,
`@include:([\w-]+):\{([^}]*)\}`,
}
for _, pat := range patterns {
re := regexp.MustCompile(pat)
template = re.ReplaceAllStringFunc(template, func(match string) string {
parts := re.FindStringSubmatch(match)
if len(parts) < 3 {
return match
}
name := parts[1]
propStr := html.UnescapeString(parts[2])
comp := LoadComponent(name)
if comp == nil {
if DevMode {
Log().Warn("include referenced unknown component '%s'", name)
}
return match
}
props := map[string]any{}
propRe := rePropKV
for _, m := range propRe.FindAllStringSubmatch(propStr, -1) {
props[m[1]] = m[2]
}
if hc, ok := comp.(*HTMLComponent); ok {
hc.Props = props
}
placeholder := fmt.Sprintf("inc-%s-%d", name, idx)
idx++
c.AddDependency(placeholder, comp)
return "@include:" + placeholder
})
}
return template
}
extractSlotContents
Parameters
Returns
func extractSlotContents(template string, c *HTMLComponent) string
{
slotRegex := reSlotNamed
return slotRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := slotRegex.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
depName := parts[1]
slotName := parts[2]
if slotName == "" {
slotName = "default"
}
content := parts[3]
if dep, ok := c.Dependencies[depName]; ok {
dep.SetSlots(map[string]any{slotName: content})
return ""
}
if DevMode {
Log().Warn("component %s missing dependency '%s' for slot '%s'", c.Name, depName, slotName)
}
return match
})
}
replaceSlotPlaceholders
Parameters
Returns
func replaceSlotPlaceholders(template string, c *HTMLComponent) string
{
slotRegex := reSlotDefault
idx := 0
return slotRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := slotRegex.FindStringSubmatch(match)
if len(parts) < 3 {
return match
}
slotName := parts[1]
if slotName == "" {
slotName = "default"
}
fallback := parts[2]
if content, ok := c.Slots[slotName]; ok {
switch v := content.(type) {
case string:
return v
case Component:
placeholder := fmt.Sprintf("slot-%s-%d", slotName, idx)
idx++
c.AddDependency(placeholder, v)
return fmt.Sprintf("@include:%s", placeholder)
default:
return fallback
}
}
return fallback
})
}
escapeValue
escapeValue renders a substituted value HTML-escaped: template bindings are
text by default; @rawstore/@rawprop opt into trusted markup injection.
Parameters
Returns
func escapeValue(v any) string
{
return html.EscapeString(fmt.Sprintf("%v", v))
}
replaceStorePlaceholders
Parameters
Returns
func replaceStorePlaceholders(template string, c *HTMLComponent) string
{
template = reRawStore.ReplaceAllStringFunc(template, func(match string) string {
parts := reRawStore.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
module, storeName, key := parts[1], parts[2], parts[3]
store := state.GlobalStoreManager.GetStore(module, storeName)
if store == nil {
return match
}
value := store.Get(key)
if value == nil {
value = ""
}
unsubscribe := store.OnChange(key, func(newValue any) {
updateStoreBindings(c, module, storeName, key, newValue)
})
c.unsubscribes.Add(unsubscribe)
return fmt.Sprintf(`<span data-store-raw="%s.%s.%s">%v</span>`, module, storeName, key, value)
})
storeRegex := reStore
return storeRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := storeRegex.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
module := parts[1]
storeName := parts[2]
key := parts[3]
isWriteable := len(parts) == 5 && parts[4] == ":w"
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
value := store.Get(key)
if value == nil {
value = ""
}
unsubscribe := store.OnChange(key, func(newValue any) {
updateStoreBindings(c, module, storeName, key, newValue)
})
c.unsubscribes.Add(unsubscribe)
if isWriteable {
return match
}
return fmt.Sprintf(`<span data-store="%s.%s.%s">%s</span>`, module, storeName, key, escapeValue(value))
}
if DevMode {
Log().Warn("store %s.%s not found for key '%s' in component %s", module, storeName, key, c.Name)
}
return match
})
}
replaceSignalPlaceholders
Parameters
Returns
func replaceSignalPlaceholders(template string, c *HTMLComponent) string
{
sigRegex := reSignal
return sigRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := sigRegex.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
name := parts[1]
isWriteable := len(parts) == 3 && parts[2] == ":w"
if prop, ok := c.Props[name]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
dom.RegisterSignal(c.ID, name, sig)
val := sig.Read()
unsub := state.Effect(func() func() {
v := sig.Read()
updateSignalBindings(c, name, v)
return nil
})
c.unsubscribes.Add(unsub)
if isWriteable {
return match
}
return fmt.Sprintf(`<span data-signal="%s">%s</span>`, name, escapeValue(val))
}
}
if DevMode {
Log().Warn("signal '%s' not found in component %s", name, c.Name)
}
return match
})
}
replaceExprPlaceholders
Parameters
Returns
func replaceExprPlaceholders(template string, c *HTMLComponent) string
{
exprRegex := reExpr
idx := 0
return exprRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := exprRegex.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
exprStr := strings.TrimSpace(parts[1])
exprID := fmt.Sprintf("expr-%d", idx)
idx++
astExpr := rtmlast.ParseExpr(exprStr)
initialVal := evalASTExprWithSigRefs(astExpr, c, nil)
c.exprContents[exprID] = exprToString(astExpr)
sigRefs := collectExprSignals(astExpr, c)
unsub := state.Effect(func() func() {
newVal := evalASTExprWithSigRefs(astExpr, c, sigRefs)
updateExprBindings(c, exprID, newVal)
return nil
})
c.unsubscribes.Add(unsub)
return fmt.Sprintf(`<span data-expr="%s">%s</span>`, exprID, escapeValue(initialVal))
})
}
replaceExprInClassAttr
Parameters
Returns
func replaceExprInClassAttr(template string, c *HTMLComponent) string
{
classRe := regexp.MustCompile(`class="([^"]*@expr:[^"]*)"`)
idx := 0
result := classRe.ReplaceAllStringFunc(template, func(match string) string {
parts := classRe.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
classVal := parts[1]
exprInAttrRe := regexp.MustCompile(`@expr:((?:[^"<@]|'[^']*')+)`)
var exprIDs []string
newClassVal := exprInAttrRe.ReplaceAllStringFunc(classVal, func(exprMatch string) string {
eparts := exprInAttrRe.FindStringSubmatch(exprMatch)
if len(eparts) < 2 {
return exprMatch
}
exprStr := strings.TrimSpace(eparts[1])
if exprStr == "" || len(exprStr) == 1 && (exprStr[0] == '\'' || exprStr[0] == '"') {
return exprMatch
}
exprID := fmt.Sprintf("class-expr-%d", idx)
idx++
exprIDs = append(exprIDs, exprID)
astExpr := rtmlast.ParseExpr(exprStr)
initialVal := evalASTExprWithSigRefs(astExpr, c, nil)
dynamicVal := strings.TrimSpace(fmt.Sprintf("%v", initialVal))
c.classExprContents[exprID] = dynamicVal
c.exprContents[exprID] = exprToString(astExpr)
sigRefs := collectExprSignals(astExpr, c)
unsub := state.Effect(func() func() {
newVal := evalASTExprWithSigRefs(astExpr, c, sigRefs)
updateClassExprBindings(c, exprID, newVal)
return nil
})
c.unsubscribes.Add(unsub)
return dynamicVal
})
idsStr := strings.Join(exprIDs, " ")
return fmt.Sprintf(`class="%s" data-expr-class="%s"`, newClassVal, idsStr)
})
return result
}
collectExprSignals
Parameters
Returns
func collectExprSignals(expr rtmlast.Expr, c *HTMLComponent) map[string]any
{
refs := make(map[string]any)
collectIdents(expr, c, refs)
return refs
}
collectIdents
Parameters
func collectIdents(expr rtmlast.Expr, c *HTMLComponent, refs map[string]any)
{
switch e := expr.(type) {
case rtmlast.IdentExpr:
name := e.Name
if strings.HasPrefix(name, "store:") || strings.HasPrefix(name, "signal:") {
return
}
if _, seen := refs[name]; !seen {
if prop, ok := c.Props[name]; ok {
refs[name] = prop
}
}
case rtmlast.BinaryExpr:
collectIdents(e.LHS, c, refs)
collectIdents(e.RHS, c, refs)
case rtmlast.UnaryExpr:
collectIdents(e.Expr, c, refs)
case rtmlast.FieldExpr:
collectIdents(e.Obj, c, refs)
case rtmlast.TernaryExpr:
collectIdents(e.Cond, c, refs)
collectIdents(e.Then, c, refs)
collectIdents(e.Else, c, refs)
}
}
evalASTExprWithSigRefs
Parameters
Returns
func evalASTExprWithSigRefs(expr rtmlast.Expr, c *HTMLComponent, sigRefs map[string]any) any
{
switch e := expr.(type) {
case rtmlast.IdentExpr:
name := e.Name
if strings.HasPrefix(name, "store:") {
parts := strings.Split(strings.TrimPrefix(name, "store:"), ".")
if len(parts) == 3 {
store := state.GlobalStoreManager.GetStore(parts[0], parts[1])
if store != nil {
return store.Get(parts[2])
}
}
return nil
}
if strings.HasPrefix(name, "signal:") {
sigName := strings.TrimPrefix(name, "signal:")
if prop, ok := c.Props[sigName]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
return sig.Read()
}
return prop
}
return nil
}
if prop, ok := sigRefs[name]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
return sig.Read()
}
return prop
}
if prop, ok := c.Props[name]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
return sig.Read()
}
return prop
}
return nil
case rtmlast.LiteralExpr:
return e.Value
case rtmlast.BinaryExpr:
switch e.Op {
case rtmlast.OpEq:
return cmpASTEqual(evalASTExprWithSigRefs(e.LHS, c, sigRefs), evalASTExprWithSigRefs(e.RHS, c, sigRefs))
case rtmlast.OpNeq:
return !cmpASTEqual(evalASTExprWithSigRefs(e.LHS, c, sigRefs), evalASTExprWithSigRefs(e.RHS, c, sigRefs))
case rtmlast.OpAnd:
return toASTBool(evalASTExprWithSigRefs(e.LHS, c, sigRefs)) && toASTBool(evalASTExprWithSigRefs(e.RHS, c, sigRefs))
case rtmlast.OpOr:
return toASTBool(evalASTExprWithSigRefs(e.LHS, c, sigRefs)) || toASTBool(evalASTExprWithSigRefs(e.RHS, c, sigRefs))
case rtmlast.OpLt, rtmlast.OpGt, rtmlast.OpLte, rtmlast.OpGte:
return cmpASTValues(evalASTExprWithSigRefs(e.LHS, c, sigRefs), evalASTExprWithSigRefs(e.RHS, c, sigRefs), e.Op)
default:
lhs := toASTFloat(evalASTExprWithSigRefs(e.LHS, c, sigRefs))
rhs := toASTFloat(evalASTExprWithSigRefs(e.RHS, c, sigRefs))
switch e.Op {
case rtmlast.OpAdd:
return lhs + rhs
case rtmlast.OpSub:
return lhs - rhs
case rtmlast.OpMul:
return lhs * rhs
case rtmlast.OpDiv:
if rhs == 0 {
return 0.0
}
return lhs / rhs
}
}
case rtmlast.UnaryExpr:
val := evalASTExprWithSigRefs(e.Expr, c, sigRefs)
switch e.Op {
case rtmlast.UnaryNot:
return !toASTBool(val)
case rtmlast.UnaryNeg:
return -toASTFloat(val)
}
case rtmlast.FieldExpr:
obj := evalASTExprWithSigRefs(e.Obj, c, sigRefs)
if m, ok := obj.(map[string]any); ok {
return m[e.Field]
}
return nil
case rtmlast.TernaryExpr:
if toASTBool(evalASTExprWithSigRefs(e.Cond, c, sigRefs)) {
return evalASTExprWithSigRefs(e.Then, c, sigRefs)
}
return evalASTExprWithSigRefs(e.Else, c, sigRefs)
}
return nil
}
cmpASTEqual
Parameters
Returns
func cmpASTEqual(a, b any) bool
{
switch av := a.(type) {
case string:
if bv, ok := b.(string); ok {
return av == bv
}
case bool:
if bv, ok := b.(bool); ok {
return av == bv
}
case int:
if bv, ok := b.(int); ok {
return av == bv
}
case float64:
if bv, ok := b.(float64); ok {
return av == bv
}
}
af, aok := toASTFloatOk(a)
bf, bok := toASTFloatOk(b)
if aok && bok {
return af == bf
}
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
cmpASTValues
Parameters
Returns
func cmpASTValues(a, b any, op rtmlast.BinOp) bool
{
l, r := toASTFloat(a), toASTFloat(b)
switch op {
case rtmlast.OpLt:
return l < r
case rtmlast.OpGt:
return l > r
case rtmlast.OpLte:
return l <= r
case rtmlast.OpGte:
return l >= r
default:
return false
}
}
toASTFloat
Parameters
Returns
func toASTFloat(v any) float64
{
switch val := v.(type) {
case int:
return float64(val)
case int64:
return float64(val)
case float64:
return val
case float32:
return float64(val)
case string:
f, err := strconv.ParseFloat(val, 64)
if err != nil {
return 0
}
return f
default:
return 0
}
}
toASTFloatOk
Parameters
Returns
func toASTFloatOk(v any) (float64, bool)
{
switch val := v.(type) {
case int:
return float64(val), true
case int64:
return float64(val), true
case float64:
return val, true
case float32:
return float64(val), true
default:
return 0, false
}
}
toASTBool
Parameters
Returns
func toASTBool(v any) bool
{
switch val := v.(type) {
case bool:
return val
case string:
return val != ""
case int:
return val != 0
case float64:
return val != 0
default:
return v != nil
}
}
exprToString
Parameters
Returns
func exprToString(expr rtmlast.Expr) string
{
switch e := expr.(type) {
case rtmlast.IdentExpr:
return e.Name
case rtmlast.LiteralExpr:
return fmt.Sprintf("%v", e.Value)
case rtmlast.BinaryExpr:
return fmt.Sprintf("(%s %s %s)", exprToString(e.LHS), binOpString(e.Op), exprToString(e.RHS))
case rtmlast.UnaryExpr:
switch e.Op {
case rtmlast.UnaryNot:
return fmt.Sprintf("!%s", exprToString(e.Expr))
case rtmlast.UnaryNeg:
return fmt.Sprintf("-%s", exprToString(e.Expr))
}
case rtmlast.FieldExpr:
return fmt.Sprintf("%s.%s", exprToString(e.Obj), e.Field)
case rtmlast.CallExpr:
return fmt.Sprintf("%s(%v)", e.Fn, e.Args)
case rtmlast.TernaryExpr:
return fmt.Sprintf("%s ? %s : %s", exprToString(e.Cond), exprToString(e.Then), exprToString(e.Else))
}
return ""
}
binOpString
Parameters
Returns
func binOpString(op rtmlast.BinOp) string
{
switch op {
case rtmlast.OpEq:
return "=="
case rtmlast.OpNeq:
return "!="
case rtmlast.OpLt:
return "<"
case rtmlast.OpGt:
return ">"
case rtmlast.OpLte:
return "<="
case rtmlast.OpGte:
return ">="
case rtmlast.OpAnd:
return "&&"
case rtmlast.OpOr:
return "||"
case rtmlast.OpAdd:
return "+"
case rtmlast.OpSub:
return "-"
case rtmlast.OpMul:
return "*"
case rtmlast.OpDiv:
return "/"
default:
return "?"
}
}
updateExprBindings
Parameters
func updateExprBindings(c *HTMLComponent, exprID string, newValue any)
{
element := dom.ComponentRoot(c.ID)
if element.IsNull() || element.IsUndefined() {
return
}
selector := fmt.Sprintf(`[data-expr="%s"]`, exprID)
nodes := element.Call("querySelectorAll", selector)
for i := 0; i < nodes.Length(); i++ {
node := nodes.Index(i)
node.Set("textContent", fmt.Sprintf("%v", newValue))
}
}
updateClassExprBindings
Parameters
func updateClassExprBindings(c *HTMLComponent, exprID string, newValue any)
{
element := dom.ComponentRoot(c.ID)
if element.IsNull() || element.IsUndefined() {
return
}
selector := fmt.Sprintf(`[data-expr-class="%s"]`, exprID)
nodes := element.Call("querySelectorAll", selector)
for i := 0; i < nodes.Length(); i++ {
node := nodes.Index(i)
newClassVal := strings.TrimSpace(fmt.Sprintf("%v", newValue))
oldClassVal, ok := c.classExprContents[exprID]
if !ok {
return
}
c.classExprContents[exprID] = newClassVal
currentClass := node.Get("className").String()
if currentClass == "" {
node.Set("className", newClassVal)
} else {
replaced := strings.Replace(currentClass, oldClassVal, newClassVal, 1)
if replaced == currentClass && oldClassVal != "" && newClassVal != "" {
replaced = currentClass + " " + newClassVal
}
node.Set("className", replaced)
}
}
}
replacePropPlaceholders
Parameters
Returns
func replacePropPlaceholders(template string, c *HTMLComponent) string
{
template = reRawProp.ReplaceAllStringFunc(template, func(match string) string {
parts := reRawProp.FindStringSubmatch(match)
if len(parts) != 2 {
return match
}
if value, exists := c.Props[parts[1]]; exists {
return fmt.Sprintf("%v", value)
}
return match
})
propRegex := reProp
idx := 0
return propRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := propRegex.FindStringSubmatch(match)
if len(parts) != 2 {
return match
}
propName := parts[1]
if value, exists := c.Props[propName]; exists {
switch v := value.(type) {
case Component:
placeholder := fmt.Sprintf("prop-%s-%d", propName, idx)
idx++
c.AddDependency(placeholder, v)
return fmt.Sprintf("@include:%s", placeholder)
default:
return escapeValue(v)
}
}
if DevMode {
Log().Warn("component %s missing prop '%s'", c.Name, propName)
}
return match
})
}
replacePluginPlaceholders
Parameters
Returns
func replacePluginPlaceholders(template string) string
{
varRegex := rePluginVar
template = varRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := varRegex.FindStringSubmatch(match)
if len(parts) != 3 {
return match
}
plug, name := parts[1], parts[2]
if v, ok := getRTMLVar(plug, name); ok {
return fmt.Sprintf("%v", v)
}
if DevMode {
Log().Warn("plugin variable %s.%s not found", plug, name)
}
return match
})
cmdRegex := rePluginCmd
template = cmdRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := cmdRegex.FindStringSubmatch(match)
if len(parts) != 4 {
return match
}
plug, name, suffix := parts[1], parts[2], parts[3]
return fmt.Sprintf(`data-plugin-cmd="%s.%s"%s`, plug, name, suffix)
})
return template
}
replaceHostPlaceholders
Parameters
Returns
func replaceHostPlaceholders(template string, c *HTMLComponent) string
{
varRegex := reHelperVar
template = varRegex.ReplaceAllStringFunc(template, func(match string) string {
name := varRegex.FindStringSubmatch(match)[1]
c.hostVars = append(c.hostVars, name)
expectedVal := ""
if c.Props != nil {
if v, ok := c.Props[name]; ok {
expectedVal = fmt.Sprintf("%v", v)
} else if v, ok := c.Props["h:"+name]; ok {
expectedVal = fmt.Sprintf("%v", v)
}
}
expectedAttr := html.EscapeString(expectedVal)
return fmt.Sprintf(`<span data-host-var="%s" data-host-expected="%s">%s</span>`,
name, expectedAttr, html.EscapeString(expectedVal))
})
cmdRegex := reHelperCmd
template = cmdRegex.ReplaceAllStringFunc(template, func(match string) string {
name := cmdRegex.FindStringSubmatch(match)[1]
c.hostCmds = append(c.hostCmds, name)
return fmt.Sprintf(`data-host-cmd="%s"`, name)
})
return template
}
replaceEventHandlers
Parameters
Returns
func replaceEventHandlers(template string) string
{
return dom.ExpandEvents(template)
}
replaceRtIsAttributes
replaceRtIsAttributes scans the template for elements decorated with the
rt-is attribute. The attribute’s value identifies a component registered in
the ComponentRegistry. Matching elements are replaced with an @include
placeholder so standard include processing can render the referenced
component and manage its lifecycle.
Parameters
Returns
func replaceRtIsAttributes(template string, c *HTMLComponent) string
{
re := reRtIs
idx := 0
return re.ReplaceAllStringFunc(template, func(match string) string {
parts := re.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
name := parts[3]
comp := LoadComponent(name)
if comp == nil {
if DevMode {
Log().Warn("rt-is referenced unknown component '%s'", name)
}
return match
}
placeholder := fmt.Sprintf("rtis-%s-%d", name, idx)
idx++
c.AddDependency(placeholder, comp)
return fmt.Sprintf("@include:%s", placeholder)
})
}
parseTemplate
parseTemplate parses the template string into an AST of nodes.
Parameters
Returns
func parseTemplate(template string) ([]Node, error)
{
lines := strings.Split(template, "\n")
idx := 0
return parseBlock(lines, &idx)
}
parseBlock
Parameters
Returns
func parseBlock(lines []string, idx *int) ([]Node, error)
{
var nodes []Node
for *idx < len(lines) {
line := lines[*idx]
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "@if:"):
cond := trimmed
*idx++
n, err := parseConditional(lines, idx, cond)
if err != nil {
return nil, err
}
nodes = append(nodes, n)
case strings.HasPrefix(trimmed, "@else-if:"), trimmed == "@else", trimmed == "@endif":
return nodes, nil
default:
nodes = append(nodes, &TextNode{Text: line + "\n"})
*idx++
}
}
return nodes, nil
}
parseConditional
Parameters
Returns
func parseConditional(lines []string, idx *int, firstCond string) (Node, error)
{
node := &ConditionalNode{}
children, err := parseBlock(lines, idx)
if err != nil {
return nil, err
}
node.Branches = append(node.Branches, ConditionalBranch{Condition: firstCond, Nodes: children})
for *idx < len(lines) {
trimmed := strings.TrimSpace(lines[*idx])
switch {
case strings.HasPrefix(trimmed, "@else-if:"):
cond := trimmed
*idx++
children, err := parseBlock(lines, idx)
if err != nil {
return nil, err
}
node.Branches = append(node.Branches, ConditionalBranch{Condition: cond, Nodes: children})
case trimmed == "@else":
*idx++
children, err := parseBlock(lines, idx)
if err != nil {
return nil, err
}
node.Branches = append(node.Branches, ConditionalBranch{Condition: "", Nodes: children})
case trimmed == "@endif":
*idx++
return node, nil
default:
*idx++
}
}
return node, nil
}
Uses
replaceConditionals
replaceConditionals parses conditionals using the AST and renders them.
Parameters
Returns
func replaceConditionals(template string, c *HTMLComponent) string
{
nodes, err := parseTemplate(template)
if err != nil {
return template
}
// the ids are positional: restart the numbering so a re-render maps every
// block back onto the node it painted before
c.condSeq = 0
var sb strings.Builder
for _, n := range nodes {
sb.WriteString(n.Render(c))
}
return sb.String()
}
evaluateCondition
Parameters
Returns
func evaluateCondition(condition string, c *HTMLComponent) (bool, []ConditionDependency)
{
expr := condition
expr = strings.TrimPrefix(expr, "@if:")
expr = strings.TrimPrefix(expr, "@else-if:")
expr = strings.TrimSpace(expr)
dependencies := extractDependencies(expr)
lookup := func(name string) (any, bool) {
if strings.HasPrefix(name, "store:") {
parts := strings.Split(strings.TrimPrefix(name, "store:"), ".")
if len(parts) == 3 {
store := state.GlobalStoreManager.GetStore(parts[0], parts[1])
if store != nil {
return store.Get(parts[2]), true
}
}
return nil, false
}
if strings.HasPrefix(name, "signal:") {
sigName := strings.TrimPrefix(name, "signal:")
if prop, ok := c.Props[sigName]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
return sig.Read(), true
}
}
return nil, false
}
if strings.HasPrefix(name, "prop:") {
propName := strings.TrimPrefix(name, "prop:")
if v, ok := c.Props[propName]; ok {
return v, true
}
return nil, false
}
if v, ok := c.Props[name]; ok {
if sig, ok := v.(interface{ Read() any }); ok {
return sig.Read(), true
}
return v, true
}
return nil, false
}
result, err := rtmleval.Bool(expr, lookup)
if err != nil {
Log().Debug("Condition evaluation error: %v", err)
return false, dependencies
}
return result, dependencies
}
extractDependencies
Parameters
Returns
func extractDependencies(expr string) []ConditionDependency
{
var deps []ConditionDependency
fields := depRegex.FindAllString(expr, -1)
for _, f := range fields {
if strings.HasPrefix(f, "store:") {
parts := strings.Split(strings.TrimPrefix(f, "store:"), ".")
if len(parts) == 3 {
deps = append(deps, ConditionDependency{module: parts[0], storeName: parts[1], key: parts[2]})
}
} else if strings.HasPrefix(f, "signal:") {
deps = append(deps, ConditionDependency{signal: strings.TrimPrefix(f, "signal:")})
}
}
return deps
}
updateStoreBindings
Parameters
func updateStoreBindings(c *HTMLComponent, module, storeName, key string, newValue any)
{
element := dom.ComponentRoot(c.ID)
if element.IsNull() || element.IsUndefined() {
return
}
selector := fmt.Sprintf(`[data-store="%s.%s.%s"]`, module, storeName, key)
nodes := element.Call("querySelectorAll", selector)
for i := 0; i < nodes.Length(); i++ {
nodes.Index(i).Set("textContent", fmt.Sprintf("%v", newValue))
}
rawSelector := fmt.Sprintf(`[data-store-raw="%s.%s.%s"]`, module, storeName, key)
rawNodes := element.Call("querySelectorAll", rawSelector)
for i := 0; i < rawNodes.Length(); i++ {
rawNodes.Index(i).Set("innerHTML", fmt.Sprintf("%v", newValue))
}
placeholder := fmt.Sprintf("@store:%s.%s.%s:w", module, storeName, key)
// Update value-based inputs and selects
inputSelector := fmt.Sprintf(`input[value="%s"], select[value="%s"]`, placeholder, placeholder)
inputs := element.Call("querySelectorAll", inputSelector)
for i := 0; i < inputs.Length(); i++ {
input := inputs.Index(i)
input.Set("value", fmt.Sprintf("%v", newValue))
}
// Update checkboxes bound via checked attribute
checkedSelector := fmt.Sprintf(`input[checked="%s"]`, placeholder)
checks := element.Call("querySelectorAll", checkedSelector)
for i := 0; i < checks.Length(); i++ {
chk := checks.Index(i)
switch v := newValue.(type) {
case bool:
chk.Set("checked", v)
case string:
chk.Set("checked", strings.ToLower(v) == "true")
default:
chk.Set("checked", newValue != nil)
}
}
// Update textareas where placeholder is in content
textareas := element.Call("querySelectorAll", "textarea")
for i := 0; i < textareas.Length(); i++ {
ta := textareas.Index(i)
if ta.Get("value").String() == placeholder {
ta.Set("value", fmt.Sprintf("%v", newValue))
}
}
updateConditionsForStoreVariable(c, module, storeName, key)
}
updateSignalBindings
Parameters
func updateSignalBindings(c *HTMLComponent, name string, newValue any)
{
element := dom.ComponentRoot(c.ID)
if element.IsNull() || element.IsUndefined() {
return
}
selector := fmt.Sprintf(`[data-signal="%s"]`, name)
nodes := element.Call("querySelectorAll", selector)
for i := 0; i < nodes.Length(); i++ {
node := nodes.Index(i)
node.Set("textContent", fmt.Sprintf("%v", newValue))
}
placeholder := fmt.Sprintf("@signal:%s:w", name)
// Update value-based inputs and selects
inputSelector := fmt.Sprintf(`input[value="%s"], select[value="%s"]`, placeholder, placeholder)
inputs := element.Call("querySelectorAll", inputSelector)
for i := 0; i < inputs.Length(); i++ {
input := inputs.Index(i)
input.Set("value", fmt.Sprintf("%v", newValue))
}
// Update checkboxes
checkedSelector := fmt.Sprintf(`input[checked="%s"]`, placeholder)
checks := element.Call("querySelectorAll", checkedSelector)
for i := 0; i < checks.Length(); i++ {
chk := checks.Index(i)
switch v := newValue.(type) {
case bool:
chk.Set("checked", v)
case string:
chk.Set("checked", strings.ToLower(v) == "true")
default:
chk.Set("checked", newValue != nil)
}
}
// Update textareas with placeholder in content
textareas := element.Call("querySelectorAll", "textarea")
for i := 0; i < textareas.Length(); i++ {
ta := textareas.Index(i)
if ta.Get("value").String() == placeholder {
ta.Set("value", fmt.Sprintf("%v", newValue))
}
}
}
insertDataKey
Parameters
Returns
func insertDataKey(content string, key any) string
{
tagRegex := reTagName
loc := tagRegex.FindStringSubmatchIndex(content)
if loc == nil {
return content
}
return content[:loc[1]] + fmt.Sprintf(` data-key="%v"`, key) + content[loc[1]:]
}
replaceConstructors
replaceConstructors scans for inline constructor tokens inside an element’s
start tag and injects the corresponding data attribute. Supported
constructors:
[name] -> data-ref=“name”
[key expr] -> data-key=“expr”
Only a single constructor per element is handled.
Parameters
Returns
func replaceConstructors(template string) string
{
re := reConditionalAttr
return re.ReplaceAllStringFunc(template, func(match string) string {
parts := re.FindStringSubmatch(match)
if len(parts) < 6 {
return match
}
tag := parts[1]
before := parts[2]
name := parts[3]
param := parts[4]
after := parts[5]
attr := ""
if name == "key" && param != "" {
attr = fmt.Sprintf(` data-key="%s"`, param)
} else if strings.HasPrefix(name, "plugin:") {
attr = fmt.Sprintf(` data-plugin="%s"`, strings.TrimPrefix(name, "plugin:"))
} else {
attr = fmt.Sprintf(` data-ref="%s"`, name)
}
return fmt.Sprintf("<%s%s%s%s>", tag, before, attr, after)
})
}
resolveNumber
Parameters
Returns
func resolveNumber(expr string, c *HTMLComponent) (int, error)
{
if n, err := strconv.Atoi(expr); err == nil {
return n, nil
}
if strings.HasPrefix(expr, "store:") {
parts := strings.Split(strings.TrimPrefix(expr, "store:"), ".")
if len(parts) == 3 {
module, storeName, key := parts[0], parts[1], parts[2]
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
if val := store.Get(key); val != nil {
unsubscribe := store.OnChange(key, func(any) {
c.requestRender()
})
c.unsubscribes.Add(unsubscribe)
switch v := val.(type) {
case int:
return v, nil
case float64:
return int(v), nil
case string:
return strconv.Atoi(v)
}
}
}
}
}
if val, ok := c.Props[expr]; ok {
switch v := val.(type) {
case int:
return v, nil
case float64:
return int(v), nil
case string:
return strconv.Atoi(v)
}
}
return 0, fmt.Errorf("invalid number")
}
updateConditionsForStoreVariable
Parameters
func updateConditionsForStoreVariable(c *HTMLComponent, module, storeName, key string)
{
for _, content := range c.conditionContents {
for _, br := range content.Branches {
if br.Condition == "" {
continue
}
dependencies, _ := getConditionDependencies(br.Condition)
for _, dep := range dependencies {
if dep.module == module && dep.storeName == storeName && dep.key == key {
c.requestRender()
return
}
}
}
}
}
getConditionDependencies
Parameters
Returns
func getConditionDependencies(condition string) ([]ConditionDependency, error)
{
expr := condition
expr = strings.TrimPrefix(expr, "@if:")
expr = strings.TrimPrefix(expr, "@else-if:")
return extractDependencies(expr), nil
}
Plugin
Plugin is a no-op stub for non-WASM builds.
type Plugin interface
Methods
Named
Named exposes a plugin name.
type Named interface
Methods
PreBuilder
PreBuilder runs before a build.
type PreBuilder interface
Methods
PostBuilder
PostBuilder runs after a build.
type PostBuilder interface
Methods
App
App is a stub holder for callbacks.
type App struct
Methods
RegisterRouter performs no work outside WASM.
Parameters
func (*App) RegisterRouter(func(string))
{}
RegisterStore performs no work outside WASM.
Parameters
func (*App) RegisterStore(func(module, store, key string, value any))
{}
RegisterLifecycle performs no work outside WASM.
Parameters
func (*App) RegisterLifecycle(func(Component), func(Component))
{}
RegisterTemplate performs no work outside WASM.
Parameters
func (*App) RegisterTemplate(func(componentID, html string))
{}
RegisterRTMLVar performs no work outside WASM.
Parameters
func (*App) RegisterRTMLVar(string, string, any)
{}
HasPlugin reports false outside WASM.
Parameters
Returns
func (*App) HasPlugin(string) bool
{ return false }
RegisterRouter adds a router navigation hook.
Parameters
func (*App) RegisterRouter(fn func(string))
{
a.routerHooks = append(a.routerHooks, fn)
}
RegisterStore adds a store mutation hook.
Parameters
func (*App) RegisterStore(fn func(module, store, key string, value any))
{
a.storeHooks = append(a.storeHooks, fn)
}
RegisterTemplate adds a template render hook.
Parameters
func (*App) RegisterTemplate(fn func(componentID, html string))
{
a.templateHooks = append(a.templateHooks, fn)
}
RegisterLifecycle adds hooks for component mount and unmount.
Parameters
func (*App) RegisterLifecycle(mount, unmount func(Component))
{
if mount != nil {
a.mountHooks = append(a.mountHooks, mount)
}
if unmount != nil {
a.unmountHooks = append(a.unmountHooks, unmount)
}
}
RegisterRTMLVar registers a value that can be referenced from RTML as {plugin:NAME.VAR}.
Parameters
func (*App) RegisterRTMLVar(plugin, name string, val any)
{
if a.pluginVars == nil {
a.pluginVars = make(map[string]map[string]any)
}
if _, ok := a.pluginVars[plugin]; !ok {
a.pluginVars[plugin] = make(map[string]any)
}
a.pluginVars[plugin][name] = val
}
HasPlugin reports whether a plugin with the given name is installed.
Parameters
Returns
func (*App) HasPlugin(name string) bool
{
if a.plugins == nil {
return false
}
_, ok := a.plugins[name]
return ok
}
RegisterPlugin
RegisterPlugin performs no work outside WASM.
Parameters
func RegisterPlugin(Plugin)
{}
Uses
TriggerRouter
TriggerRouter performs no work outside WASM.
Parameters
func TriggerRouter(string)
{}
TriggerStore
TriggerStore performs no work outside WASM.
Parameters
func TriggerStore(string, string, string, any)
{}
TriggerMount
TriggerMount performs no work outside WASM.
Parameters
func TriggerMount(Component)
{}
Uses
TriggerUnmount
TriggerUnmount performs no work outside WASM.
Parameters
func TriggerUnmount(Component)
{}
Uses
TriggerTemplate
TriggerTemplate performs no work outside WASM.
Parameters
func TriggerTemplate(string, string)
{}
OnTemplate
OnTemplate performs no work outside WASM.
Parameters
func OnTemplate(func(componentID, html string))
{}
RegisterPluginVar
RegisterPluginVar performs no work outside WASM.
Parameters
func RegisterPluginVar(string, string, any)
{}
TestProvideInject
Parameters
func TestProvideInject(t *testing.T)
{
state.NewStore("default", state.WithModule("app"))
parentTpl := []byte("<root></root>")
childTpl := []byte("<root></root>")
parent := NewComponent("Parent", parentTpl, nil)
child := NewComponent("Child", childTpl, nil)
parent.Provide("answer", 42)
parent.AddDependency("child", child)
v, ok := Inject[int](child, "answer")
if !ok || v != 42 {
t.Fatalf("expected injected 42, got %v", v)
}
}
devOverrideTemplate
Parameters
Returns
func devOverrideTemplate(_ *HTMLComponent, template string) string
{ return template }
devRegisterComponent
Parameters
func devRegisterComponent(*HTMLComponent)
{}
devUnregisterComponent
Parameters
func devUnregisterComponent(*HTMLComponent)
{}
TryRender
TryRender wraps a component’s Render() with panic recovery.
If a panic occurs, it shows the error overlay and returns empty string
so the app stays alive rather than dying to a white screen.
Parameters
Returns
func TryRender(c Component) string
{
defer func() {
if r := recover(); r != nil {
ReportError(r, fmt.Sprintf("Render: %s (ID: %s)", c.GetName(), c.GetID()))
}
}()
return c.Render()
}
Uses
TryMount
TryMount wraps a component’s Mount() with panic recovery.
Parameters
func TryMount(c Component)
{
defer func() {
if r := recover(); r != nil {
ReportError(r, fmt.Sprintf("Mount: %s (ID: %s)", c.GetName(), c.GetID()))
}
}()
c.Mount()
}
Uses
TryUnmount
TryUnmount wraps a component’s Unmount() with panic recovery.
Parameters
func TryUnmount(c Component)
{
defer func() {
if r := recover(); r != nil {
ReportError(r, fmt.Sprintf("Unmount: %s (ID: %s)", c.GetName(), c.GetID()))
}
}()
c.Unmount()
}
Uses
TryEffect
TryEffect wraps an effect function with panic recovery.
Parameters
Returns
func TryEffect(fn func() func()) func()
{
return state.Effect(func() func() {
defer func() {
if r := recover(); r != nil {
ReportError(r, "Effect")
debug.PrintStack()
}
}()
return fn()
})
}
TryTemplateLoad
TryTemplateLoad wraps template loading with recovery.
Parameters
func TryTemplateLoad(fn func())
{
defer func() {
if r := recover(); r != nil {
ReportError(r, "Template / Composition")
}
}()
fn()
}
unsubscribes
type unsubscribes struct
Methods
Fields
| Name | Type | Description |
|---|---|---|
| funcs | []func() |
HTMLComponent
HTMLComponent renders RTML templates and manages their component state.
type HTMLComponent struct
Methods
Stats returns zeroed metrics on non-wasm builds.
Returns
func (*HTMLComponent) Stats() ComponentStats
{ return ComponentStats{} }
requestRender invalidates a mounted component and coalesces its DOM work with every other request made in the same JavaScript turn. Rendering is delayed, not state capture: the flush always observes the latest store and signal data.
func (*HTMLComponent) requestRender()
{
if c == nil || !c.mounted {
return
}
c.Invalidate()
requestScheduledRender(renderJob{
id: c.ID,
depth: componentDepth(c),
active: func() bool {
return c.mounted
},
render: func() {
dom.UpdateMountedDOM(c.ID, c.RenderFresh())
},
})
}
Render returns no markup outside WASM.
Returns
func (*HTMLComponent) Render() string
{ return "" }
GetName returns the component name.
Returns
func (*HTMLComponent) GetName() string
{ return c.Name }
GetID returns the component ID.
Returns
func (*HTMLComponent) GetID() string
{ return c.ID }
SetSlots performs no work outside WASM.
Parameters
func (*HTMLComponent) SetSlots(map[string]any)
{}
Scope returns the component lifecycle scope.
Returns
func (*HTMLComponent) Scope() *Scope
{
if c.scope == nil || c.scope.Closed() {
c.scope = NewScope()
}
return c.scope
}
Init attaches a state store and prepares the component template.
Parameters
func (*HTMLComponent) Init(store *state.Store)
{
if c.Store != nil {
return
}
template, err := LoadComponentTemplate(c.TemplateFS)
if err != nil {
panic(fmt.Sprintf("Error loading template for component %s: %v", c.Name, err))
}
template = devOverrideTemplate(c, template)
c.Template = template
dom.RegisterBindings(c.ID, c.Name, template)
devRegisterComponent(c)
if store != nil {
c.Store = store
} else {
c.Store = state.GlobalStoreManager.GetStore("app", "default")
if c.Store == nil {
c.Store = state.NewStore("default", state.WithModule("app"))
}
}
}
RenderFresh clears the render cache and re-renders. Reactive updates (store OnChange, signal effects) call this so a state change always produces up-to-date HTML instead of a stale cached render. Fixes the bug where a store.Set did not re-render @for / @expr / store-bound templates because the cache key hashes only Props/Dependencies, not the bound store state.
Returns
func (*HTMLComponent) RenderFresh() string
{
c.Invalidate()
return c.Render()
}
Invalidate drops the render cache of this component and of everything it includes. The cache key covers props and dependency identity, never the store state a template binds to, so an included component handed back its first render forever: a dependency whose markup depends on a shared store key (an @if on a global flag) froze at the value it had when the parent first painted.
func (*HTMLComponent) Invalidate()
{
c.cache = nil
c.lastCacheKey = ""
for _, dep := range c.Dependencies {
if d, ok := dep.(interface{ Invalidate() }); ok {
d.Invalidate()
}
}
}
Render evaluates the component template.
Returns
func (*HTMLComponent) Render() (renderedTemplate string)
{
start := time.Now()
defer func() { c.recordRender(time.Since(start)) }()
key := c.cacheKey()
if c.cache != nil {
if val, ok := c.cache[key]; ok {
renderedTemplate = val
return
}
if c.lastCacheKey != "" && c.lastCacheKey != key {
delete(c.cache, c.lastCacheKey)
}
} else {
c.cache = make(map[string]string)
}
defer func() {
if r := recover(); r != nil {
ReportError(r, fmt.Sprintf("Render: %s (ID: %s)", c.Name, c.ID))
renderedTemplate = ""
}
}()
c.unsubscribes.Run()
renderedTemplate = c.Template
renderedTemplate = strings.Replace(renderedTemplate, "<root", fmt.Sprintf("<root data-component-id=\"%s\"", c.ID), 1)
// Extract slot contents destined for child components
renderedTemplate = extractSlotContents(renderedTemplate, c)
// Replace this component's slot placeholders with provided content or fallbacks
renderedTemplate = replaceSlotPlaceholders(renderedTemplate, c)
// {{prop}} substitutions are HTML-escaped like @prop; @rawprop remains the
// explicit escape hatch for trusted markup.
for key, value := range c.Props {
placeholder := fmt.Sprintf("{{%s}}", key)
renderedTemplate = strings.ReplaceAll(renderedTemplate, placeholder, escapeValue(value))
}
// Register @include directives that supply inline props
renderedTemplate = replaceComponentIncludes(renderedTemplate, c)
// Handle @include:componentName syntax for dependencies
renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)
// Handle @for loops
renderedTemplate = replaceForPlaceholders(renderedTemplate, c)
renderedTemplate = replaceStorePlaceholders(renderedTemplate, c)
renderedTemplate = replaceSignalPlaceholders(renderedTemplate, c)
renderedTemplate = replaceExprInClassAttr(renderedTemplate, c)
renderedTemplate = replaceExprPlaceholders(renderedTemplate, c)
// Handle @prop:propName syntax for props
renderedTemplate = replacePropPlaceholders(renderedTemplate, c)
// Handle plugin variable and command placeholders
renderedTemplate = replacePluginPlaceholders(renderedTemplate)
// Handle host variable and command placeholders
if len(c.hostComponentNames()) > 0 {
renderedTemplate = replaceHostPlaceholders(renderedTemplate, c)
}
// Handle @if:condition syntax for conditional rendering
renderedTemplate = replaceConditionals(renderedTemplate, c)
// Handle @on:event:handler and @event:handler syntax for event binding
renderedTemplate = replaceEventHandlers(renderedTemplate)
// Handle rt-is="ComponentName" for dynamic component loading
renderedTemplate = replaceRtIsAttributes(renderedTemplate, c)
// Render any components introduced via rt-is placeholders
renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)
// Handle constructor decorators like [ref] and [key expr]
renderedTemplate = replaceConstructors(renderedTemplate)
for _, name := range c.hostComponentNames() {
hostclient.RegisterComponent(c.ID, name, c.hostVars)
}
renderedTemplate = minifyInline(renderedTemplate)
c.cache[key] = renderedTemplate
c.lastCacheKey = key
return renderedTemplate
}
Parameters
func (*HTMLComponent) recordRender(duration time.Duration)
{
if c == nil {
return
}
c.metricsMu.Lock()
c.renderCount++
c.totalRender += duration
c.lastRender = duration
c.appendTimelineLocked(ComponentTimelineEntry{
Kind: "render",
Timestamp: time.Now(),
Duration: duration,
})
c.metricsMu.Unlock()
}
Parameters
func (*HTMLComponent) appendTimelineLocked(entry ComponentTimelineEntry)
{
if entry.Kind == "" {
return
}
if c.timeline == nil {
c.timeline = make([]ComponentTimelineEntry, 0, 8)
}
c.timeline = append(c.timeline, entry)
if len(c.timeline) > componentTimelineLimit {
c.timeline = append([]ComponentTimelineEntry(nil), c.timeline[len(c.timeline)-componentTimelineLimit:]...)
}
}
Stats returns a snapshot of the component's render metrics.
Returns
func (*HTMLComponent) Stats() ComponentStats
{
c.metricsMu.Lock()
defer c.metricsMu.Unlock()
stats := ComponentStats{
RenderCount: c.renderCount,
TotalRender: c.totalRender,
LastRender: c.lastRender,
}
if c.renderCount > 0 {
stats.AverageRender = c.totalRender / time.Duration(c.renderCount)
}
if len(c.timeline) > 0 {
stats.Timeline = append(stats.Timeline, c.timeline...)
}
return stats
}
AddDependency attaches a child component to a template placeholder.
Parameters
func (*HTMLComponent) AddDependency(placeholderName string, dep Component)
{
if c.Dependencies == nil {
c.Dependencies = make(map[string]Component)
}
if depComp, ok := dep.(*HTMLComponent); ok {
depComp.Init(c.Store)
depComp.parent = c
}
c.Dependencies[placeholderName] = dep
}
Unmount releases component resources and child dependencies.
func (*HTMLComponent) Unmount()
{
// The idempotence guard keeps finalizers from repeating lifecycle cleanup.
if !c.mounted {
return
}
c.mounted = false
cancelComponentRender(c.ID)
devUnregisterComponent(c)
if c.component != nil {
c.runLifecycle("OnUnmount", c.component.OnUnmount)
}
dom.UnmountLifecycleHooks(c.ID)
c.releaseDOMHooks()
if c.scope != nil {
c.scope.Close()
}
dom.RemoveComponentSignals(c.ID)
dom.ReleaseInputBindings(c.ID)
dom.ReleaseComponentHandlers(c.ID)
root := dom.ComponentRoot(c.ID)
if !root.IsNull() && !root.IsUndefined() {
dom.RemoveDelegatedEvents(c.ID, root.Value)
}
log.Printf("Unsubscribing %s from all stores", c.Name)
c.unsubscribes.Run()
for _, dep := range c.Dependencies {
dependency := dep
c.runLifecycle("dependency unmount", dependency.Unmount)
}
}
Mount activates the component and its child dependencies.
func (*HTMLComponent) Mount()
{
c.mounted = true
if c.scope == nil || c.scope.Closed() {
c.scope = NewScope()
}
c.registerHandlers()
c.registerDOMHooks()
for _, dep := range c.Dependencies {
dependency := dep
c.runLifecycle("dependency mount", dependency.Mount)
}
root := dom.ComponentRoot(c.ID)
if !root.IsNull() && !root.IsUndefined() {
dom.DelegateEvents(c.ID, root.Value)
}
if c.component != nil {
c.runLifecycle("OnMount", c.component.OnMount)
}
dom.MountLifecycleHooks(c.ID)
}
Parameters
func (*HTMLComponent) runLifecycle(phase string, fn func())
{
defer func() {
if recovered := recover(); recovered != nil {
ReportError(recovered, phase+": "+c.Name+" (ID: "+c.ID+")")
}
}()
fn()
}
Scope returns the lifecycle scope owned by this component.
Returns
func (*HTMLComponent) Scope() *Scope
{
if c.scope == nil || c.scope.Closed() {
c.scope = NewScope()
}
return c.scope
}
Effect registers a reactive effect that stops on unmount.
Parameters
func (*HTMLComponent) Effect(fn func() func())
{
c.Scope().Defer(state.Effect(fn))
}
DOMHook registers root lifecycle callbacks owned by this component.
Parameters
func (*HTMLComponent) DOMHook(hook dom.LifecycleHook)
{
c.domHooks = append(c.domHooks, hook)
if c.mounted {
c.domHookStops = append(c.domHookStops, dom.RegisterLifecycleHook(c.ID, hook))
dom.MountLifecycleHooks(c.ID)
}
}
func (*HTMLComponent) registerDOMHooks()
{
c.releaseDOMHooks()
for _, hook := range c.domHooks {
c.domHookStops = append(c.domHookStops, dom.RegisterLifecycleHook(c.ID, hook))
}
}
func (*HTMLComponent) releaseDOMHooks()
{
for _, stop := range c.domHookStops {
stop()
}
c.domHookStops = nil
}
On registers an event handler owned by this component instance.
Parameters
func (*HTMLComponent) On(name string, fn func())
{
if name == "" {
panic("core.HTMLComponent.On: empty handler name")
}
if fn == nil {
panic("core.HTMLComponent.On: nil fn")
}
c.handlers[name] = fn
dom.RegisterComponentHandlerFunc(c.ID, name, fn)
}
func (*HTMLComponent) registerHandlers()
{
for name, fn := range c.handlers {
dom.RegisterComponentHandlerFunc(c.ID, name, fn)
}
}
GetName returns the component name.
Returns
func (*HTMLComponent) GetName() string
{
return c.Name
}
GetID returns the component identifier.
Returns
func (*HTMLComponent) GetID() string
{
return c.ID
}
GetRef returns the DOM element annotated with a matching constructor decorator. It searches within this component's root element using the data-ref attribute injected during template rendering.
Parameters
Returns
func (*HTMLComponent) GetRef(name string) dom.Element
{
root := dom.ComponentRoot(c.ID)
if root.IsNull() || root.IsUndefined() {
return dom.Element{}
}
return root.Query(fmt.Sprintf(`[data-ref="%s"]`, name))
}
OnMount runs the configured mount callback.
func (*HTMLComponent) OnMount()
{
if c.onMount != nil {
c.onMount(c)
}
}
OnUnmount runs the configured unmount callback.
func (*HTMLComponent) OnUnmount()
{
if c.onUnmount != nil {
c.onUnmount(c)
}
c.mounted = false
}
IsMounted reports whether the component is mounted.
Returns
func (*HTMLComponent) IsMounted() bool
{
return c.mounted
}
OnParams runs the configured route-parameter callback.
Parameters
func (*HTMLComponent) OnParams(params map[string]string)
{
if c.onParams != nil {
c.onParams(c, params)
}
}
SetOnParams configures the route-parameter callback.
Parameters
func (*HTMLComponent) SetOnParams(fn func(*HTMLComponent, map[string]string))
{
c.onParams = fn
}
SetOnMount configures the mount callback.
Parameters
func (*HTMLComponent) SetOnMount(fn func(*HTMLComponent))
{
c.onMount = fn
}
SetOnUnmount configures the unmount callback.
Parameters
func (*HTMLComponent) SetOnUnmount(fn func(*HTMLComponent))
{
c.onUnmount = fn
}
WithLifecycle configures mount and unmount callbacks.
Parameters
Returns
func (*HTMLComponent) WithLifecycle(onMount, onUnmount func(*HTMLComponent)) *HTMLComponent
{
c.onMount = onMount
c.onUnmount = onUnmount
return c
}
SetComponent attaches the component lifecycle implementation.
Parameters
func (*HTMLComponent) SetComponent(component Component)
{
c.component = component
}
SetSlots merges named slot content into the component.
Parameters
func (*HTMLComponent) SetSlots(slots map[string]any)
{
if c.Slots == nil {
c.Slots = make(map[string]any)
}
for k, v := range slots {
c.Slots[k] = v
}
}
Provide stores a value on this component so that descendants can retrieve it with Inject. It creates the map on first use.
Parameters
func (*HTMLComponent) Provide(key string, val any)
{
if c.provides == nil {
c.provides = make(map[string]any)
}
c.provides[key] = val
}
Inject searches for a provided value starting from this component and walking up the parent chain. It returns the value as `any` and whether it was found. Callers can type-assert the result.
Parameters
Returns
func (*HTMLComponent) Inject(key string) (any, bool)
{
if c.provides != nil {
if v, ok := c.provides[key]; ok {
return v, true
}
}
if c.parent != nil {
return c.parent.Inject(key)
}
return nil, false
}
SetRouteParams merges route parameters into component props.
Parameters
func (*HTMLComponent) SetRouteParams(params map[string]string)
{
if c.Props == nil {
c.Props = make(map[string]any)
}
for k, v := range params {
c.Props[k] = v
}
}
AddHostComponent links this HTML component to a server-side HostComponent by name. When running in SSC mode, messages from the wasm runtime will be routed to the corresponding host component on the server. It may be called multiple times (e.g. a composition struct with several host fields): every name is registered, and HostComponent keeps the first one as the primary.
Parameters
func (*HTMLComponent) AddHostComponent(name string)
{
for _, n := range c.hostComponents {
if n == name {
return
}
}
c.hostComponents = append(c.hostComponents, name)
if c.HostComponent == "" {
c.HostComponent = name
}
}
hostComponentNames returns every host component linked to this component, including a HostComponent assigned directly to the exported field.
Returns
func (*HTMLComponent) hostComponentNames() []string
{
if len(c.hostComponents) > 0 {
return c.hostComponents
}
if c.HostComponent != "" {
return []string{c.HostComponent}
}
return nil
}
Returns
func (*HTMLComponent) cacheKey() string
{
hasher := sha256.New()
hasher.Write([]byte(serializeProps(c.Props)))
if len(c.Dependencies) > 0 {
deps := make([]string, 0, len(c.Dependencies))
for name, dep := range c.Dependencies {
deps = append(deps, name+dep.GetID())
}
sort.Strings(deps)
for _, d := range deps {
hasher.Write([]byte(d))
}
}
return hex.EncodeToString(hasher.Sum(nil)[:20])
}
Fields
| Name | Type | Description |
|---|---|---|
| ID | string | |
| Name | string | |
| Template | string | |
| TemplateFS | []byte | |
| Dependencies | map[string]Component | |
| unsubscribes | unsubscribes | |
| Store | *state.Store | |
| Props | map[string]any | |
| Slots | map[string]any | |
| HostComponent | string | |
| hostComponents | []string | |
| conditionContents | map[string]ConditionContent | |
| condSeq | int | |
| forSeq | int | |
| exprContents | map[string]string | |
| classExprContents | map[string]string | |
| hostVars | []string | |
| hostCmds | []string | |
| component | Component | |
| mounted | bool | |
| onMount | func(*HTMLComponent) | |
| onUnmount | func(*HTMLComponent) | |
| onParams | func(*HTMLComponent, map[string]string) | |
| handlers | map[string]func() | |
| domHooks | []dom.LifecycleHook | |
| domHookStops | []func() | |
| scope | *Scope | |
| parent | *HTMLComponent | |
| provides | map[string]any | |
| cache | map[string]string | |
| lastCacheKey | string | |
| metricsMu | sync.Mutex | |
| renderCount | int | |
| totalRender | time.Duration | |
| lastRender | time.Duration | |
| timeline | []ComponentTimelineEntry |
ComponentStats
ComponentStats contains aggregated render metrics for an HTML component.
type ComponentStats struct
Fields
| Name | Type | Description |
|---|---|---|
| RenderCount | int | |
| TotalRender | time.Duration | |
| LastRender | time.Duration | |
| AverageRender | time.Duration | |
| Timeline | []ComponentTimelineEntry |
ComponentTimelineEntry
ComponentTimelineEntry represents a point-in-time event collected for diagnostics.
type ComponentTimelineEntry struct
Fields
| Name | Type | Description |
|---|---|---|
| Kind | string | |
| Timestamp | time.Time | |
| Duration | time.Duration |
NewHTMLComponent
NewHTMLComponent creates a component from an RTML template and initial props.
Parameters
Returns
func NewHTMLComponent(name string, templateFs []byte, props map[string]any) *HTMLComponent
{
id := generateComponentID(name, props)
c := &HTMLComponent{
ID: id,
Name: name,
TemplateFS: templateFs,
Dependencies: make(map[string]Component),
Props: props,
Slots: make(map[string]any),
handlers: make(map[string]func()),
scope: NewScope(),
conditionContents: make(map[string]ConditionContent),
exprContents: make(map[string]string),
classExprContents: make(map[string]string),
}
// Attempt automatic cleanup when component is garbage collected.
runtime.SetFinalizer(c, func(hc *HTMLComponent) { hc.Unmount() })
return c
}
minifyInline
Parameters
Returns
func minifyInline(src string) string
{
inlineMinifierOnce.Do(func() {
inlineMinifier = minify.New()
inlineMinifier.AddFunc("text/javascript", tdJs.Minify)
inlineMinifier.AddFunc("text/css", css.Minify)
})
return inlineRe.ReplaceAllStringFunc(src, func(match string) string {
m := inlineRe.FindStringSubmatch(match)
tag, attrs, code := m[1], m[2], m[3]
media := "text/javascript"
if tag == "style" {
media = "text/css"
}
out, err := inlineMinifier.String(media, code)
if err != nil {
return match
}
return fmt.Sprintf("<%s%s>%s</%s>", tag, attrs, strings.TrimSpace(out), tag)
})
}
Inject
Inject performs a typed lookup of a provided component value.
Parameters
Returns
func Inject[T any](c *HTMLComponent, key string) (T, bool)
{
v, ok := c.Inject(key)
if !ok {
var zero T
return zero, false
}
t, ok := v.(T)
return t, ok
}
generateComponentID
Parameters
Returns
func generateComponentID(name string, props map[string]any) string
{
hasher := sha256.New()
hasher.Write([]byte(name))
propsString := serializeProps(props)
hasher.Write([]byte(propsString))
hasher.Write([]byte(strconv.FormatUint(componentSeq.Add(1), 10)))
return hex.EncodeToString(hasher.Sum(nil)[:20])
}
serializeProps
Parameters
Returns
func serializeProps(props map[string]any) string
{
if props == nil {
return ""
}
var sb strings.Builder
keys := make([]string, 0, len(props))
for k := range props {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := props[k]
fmt.Fprintf(&sb, "%s=%v;", k, v)
}
return sb.String()
}
TestRenderSchedulerCoalescesStoreBurst
Parameters
func TestRenderSchedulerCoalescesStoreBurst(t *testing.T)
{
store := state.NewStore("scheduler_burst", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "scheduler_burst")
store.Set("items", []any{map[string]any{"label": "0"}})
component := mountForComponent(t, "SchedulerBurst", []byte(`<root><ul>
@for:item in store:app.scheduler_burst.items
<li data-key="row">@prop:item.label</li>
@endfor
</ul></root>`))
defer component.Unmount()
before := component.Stats().RenderCount
for i := 1; i <= 20; i++ {
store.Set("items", []any{map[string]any{"label": fmt.Sprintf("%d", i)}})
}
waitForRenderFlush()
if got := component.Stats().RenderCount - before; got != 1 {
t.Fatalf("render burst produced %d renders, want 1", got)
}
if got := dom.ComponentRoot(component.ID).Query("li").Text(); got != "20" {
t.Fatalf("scheduled render used %q, want latest value 20", got)
}
}
TestRenderSchedulerCancelsUnmountedComponent
Parameters
func TestRenderSchedulerCancelsUnmountedComponent(t *testing.T)
{
store := state.NewStore("scheduler_unmount", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "scheduler_unmount")
store.Set("items", []any{map[string]any{"label": "before"}})
component := mountForComponent(t, "SchedulerUnmount", []byte(`<root><ul>
@for:item in store:app.scheduler_unmount.items
<li>@prop:item.label</li>
@endfor
</ul></root>`))
before := component.Stats().RenderCount
store.Set("items", []any{map[string]any{"label": "after"}})
component.Unmount()
waitForRenderFlush()
if got := component.Stats().RenderCount; got != before {
t.Fatalf("unmounted component rendered: before=%d after=%d", before, got)
}
}
TestRenderSchedulerContinuesAfterDOMFailure
Parameters
func TestRenderSchedulerContinuesAfterDOMFailure(t *testing.T)
{
store := state.NewStore("scheduler_recovery", state.WithModule("app"))
defer state.GlobalStoreManager.UnregisterStore("app", "scheduler_recovery")
store.Set("items", []any{map[string]any{"label": "before"}})
component := mountForComponent(t, "SchedulerRecovery", []byte(`<root><ul>
@for:item in store:app.scheduler_recovery.items
<li>@prop:item.label</li>
@endfor
</ul></root>`))
defer component.Unmount()
previousHook := dom.TemplateHook
dom.TemplateHook = func(string, string) { panic("test hook") }
store.Set("items", []any{map[string]any{"label": "failed-cycle"}})
waitForRenderFlush()
dom.TemplateHook = nil
defer func() { dom.TemplateHook = previousHook }()
store.Set("items", []any{map[string]any{"label": "recovered"}})
waitForRenderFlush()
if got := dom.ComponentRoot(component.ID).Query("li").Text(); got != "recovered" {
t.Fatalf("scheduler wedged after recovered failure: got %q", got)
}
}
TestRenderSchedulerCommitsParentBeforeChild
Parameters
func TestRenderSchedulerCommitsParentBeforeChild(t *testing.T)
{
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
child := NewHTMLComponent("ScheduledChild", []byte(`<root><span>child</span></root>`), nil)
child.SetComponent(child)
child.Init(nil)
parent := NewHTMLComponent("ScheduledParent", []byte(`<root><main>parent</main>@include:child</root>`), nil)
parent.SetComponent(parent)
parent.AddDependency("child", child)
parent.Init(nil)
dom.UpdateDOM(parent.ID, parent.Render())
parent.Mount()
defer parent.Unmount()
previousHook := dom.TemplateHook
var commits []string
dom.TemplateHook = func(componentID, _ string) {
if componentID == parent.ID || componentID == child.ID {
commits = append(commits, componentID)
}
}
defer func() { dom.TemplateHook = previousHook }()
child.requestRender()
parent.requestRender()
waitForRenderFlush()
if want := []string{parent.ID, child.ID}; !reflect.DeepEqual(commits, want) {
t.Fatalf("commit order = %v, want parent then child %v", commits, want)
}
}
TestRenderSchedulerCommitsSiblingsByStableID
Parameters
func TestRenderSchedulerCommitsSiblingsByStableID(t *testing.T)
{
var commits []string
active := func() bool { return true }
requestScheduledRender(renderJob{
id: "sibling-b",
depth: 1,
active: active,
render: func() { commits = append(commits, "sibling-b") },
})
requestScheduledRender(renderJob{
id: "sibling-a",
depth: 1,
active: active,
render: func() { commits = append(commits, "sibling-a") },
})
waitForRenderFlush()
if want := []string{"sibling-a", "sibling-b"}; !reflect.DeepEqual(commits, want) {
t.Fatalf("sibling commit order = %v, want stable ID order %v", commits, want)
}
}
waitForRenderFlush
func waitForRenderFlush()
{
time.Sleep(20 * time.Millisecond)
}
TestConditionalBranchRefreshesWhenItComesBack
A block that is hidden while the state it binds to moves on has to come back
showing the current value, not the one it carried when it left the DOM.
Parameters
func TestConditionalBranchRefreshesWhenItComesBack(t *testing.T)
{
st := state.NewStore("condrefresh", state.WithModule("app"))
st.Set("chrome", "on")
st.Set("title", "first")
host := dom.Doc().CreateElement("div")
dom.Doc().Body().AppendChild(host)
tpl := []byte(`<root>
@if:store:app.condrefresh.chrome == "on"
<header data-refresh-header>@store:app.condrefresh.title</header>
@endif
</root>`)
c := NewHTMLComponent("CondRefresh", tpl, nil)
c.SetComponent(c)
c.Init(nil)
host.SetHTML(c.Render())
c.Mount()
if got := dom.Query("[data-refresh-header]").Text(); got != "first" {
t.Fatalf("initial header = %q", got)
}
st.Set("chrome", "off")
waitForRenderFlush()
if el := dom.Query("[data-refresh-header]"); !el.IsNull() {
t.Fatal("header should be gone while the condition is false")
}
// the title moves while the block is out of the DOM
st.Set("title", "second")
st.Set("chrome", "on")
waitForRenderFlush()
el := dom.Query("[data-refresh-header]")
if el.IsNull() {
t.Fatal("header did not come back")
}
if got := strings.TrimSpace(el.Text()); got != "second" {
t.Fatalf("header came back stale: %q", got)
}
}
LoadComponentTemplate
LoadComponentTemplate validates and returns embedded template data.
Parameters
Returns
func LoadComponentTemplate(templateFs []byte) (string, error)
{
template := string(templateFs)
if template == "" {
return "", fmt.Errorf("template is empty")
}
return template, nil
}
Component
Component defines the minimal interface exposed to plugins in non-WASM builds.
type Component interface
RegisterComponent
RegisterComponent registers a component constructor for lookup by name. It
returns an error if a component with the same name has already been
registered and logs a warning.
Parameters
Returns
func RegisterComponent(name string, constructor func() Component) error
{
componentRegistryMu.Lock()
defer componentRegistryMu.Unlock()
if _, exists := ComponentRegistry[name]; exists {
Log().Warn("component %s already registered", name)
return fmt.Errorf("component %s already registered", name)
}
ComponentRegistry[name] = constructor
return nil
}
Uses
LoadComponent
LoadComponent retrieves a component constructor by name. If no component is
registered under that name, nil is returned.
Parameters
Returns
func LoadComponent(name string) Component
{
componentRegistryMu.RLock()
ctor, ok := ComponentRegistry[name]
componentRegistryMu.RUnlock()
if ok {
return ctor()
}
return nil
}
Uses
TestComponentRegistryConcurrentAccess
Parameters
func TestComponentRegistryConcurrentAccess(t *testing.T)
{
componentRegistryMu.Lock()
ComponentRegistry = map[string]func() Component{}
componentRegistryMu.Unlock()
const n = 100
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(i int) {
defer wg.Done()
name := fmt.Sprintf("comp-%d", i)
if err := RegisterComponent(name, func() Component { return noopComponent{} }); err != nil {
t.Errorf("register %s: %v", name, err)
}
if c := LoadComponent(name); c == nil {
t.Errorf("load %s: got nil", name)
}
}(i)
}
wg.Wait()
}
ErrorBoundary
ErrorBoundary wraps a child component and renders a fallback UI when the
child panics during Render or Mount. Once a panic occurs, the fallback UI is
displayed for subsequent renders.
type ErrorBoundary struct
Methods
Returns
func (*ErrorBoundary) fallbackHTML() string
{
return "<root data-component-id=\"" + e.Child.GetID() + "\">" + e.Fallback + "</root>"
}
Render renders the child component, returning the fallback HTML if the child panics or if a previous panic was recorded.
Returns
func (*ErrorBoundary) Render() (out string)
{
if e.err != nil {
return e.fallbackHTML()
}
defer func() {
if r := recover(); r != nil {
e.err = r
ReportError(r, "Boundary render: "+e.Child.GetName())
out = e.fallbackHTML()
}
}()
return e.Child.Render()
}
Mount mounts the child component, updating the DOM with the fallback HTML if the child panics during mounting.
func (*ErrorBoundary) Mount()
{
if e.err != nil {
e.mounted = true
return
}
defer func() {
if r := recover(); r != nil {
e.err = r
e.mounted = true
ReportError(r, "Boundary mount: "+e.Child.GetName())
dom.UpdateDOM(e.Child.GetID(), e.Fallback)
}
}()
e.Child.Mount()
e.mounted = true
}
Unmount delegates to the child component's Unmount method.
func (*ErrorBoundary) Unmount()
{
e.Child.Unmount()
e.mounted = false
}
GetName returns the name of the component.
Returns
func (*ErrorBoundary) GetName() string
{ return "ErrorBoundary" }
GetID returns the wrapped child's ID.
Returns
func (*ErrorBoundary) GetID() string
{ return e.Child.GetID() }
SetSlots delegates slot assignment to the child component.
Parameters
func (*ErrorBoundary) SetSlots(slots map[string]any)
{
if e.Child != nil {
e.Child.SetSlots(slots)
}
}
IsMounted reports whether the boundary or its fallback is mounted.
Returns
func (*ErrorBoundary) IsMounted() bool
{ return e.mounted }
OnParams delegates route parameters to the wrapped component.
Parameters
func (*ErrorBoundary) OnParams(params map[string]string)
{
e.Child.OnParams(params)
}
Fields
| Name | Type | Description |
|---|---|---|
| Child | Component | |
| Fallback | string | |
| err | any | |
| mounted | bool |
Uses
NewErrorBoundary
NewErrorBoundary creates a new ErrorBoundary around the provided child
component. If the child panics during Render or Mount, the provided fallback
HTML will be rendered instead.
Parameters
Returns
func NewErrorBoundary(child Component, fallback string) *ErrorBoundary
{
return &ErrorBoundary{Child: child, Fallback: fallback}
}
Uses
Logger
Logger defines logging interface used by the framework.
type Logger interface
init
func init()
{ state.SetLogger(logger) }
SetLogger
SetLogger allows applications to replace the default logger.
Parameters
func SetLogger(l Logger)
{
if l != nil {
logger = l
state.SetLogger(l)
}
}
Uses
Log
Log returns the active logger implementation.
Returns
func Log() Logger
{ return logger }
Uses
defaultLogger
defaultLogger is the fallback logger using the standard log package.
type defaultLogger struct
Methods
Parameters
func (defaultLogger) Debug(format string, v ...any)
{ log.Printf("DEBUG: "+format, v...) }
Parameters
func (defaultLogger) Info(format string, v ...any)
{ log.Printf("INFO: "+format, v...) }
Parameters
func (defaultLogger) Warn(format string, v ...any)
{ log.Printf("WARN: "+format, v...) }
Parameters
func (defaultLogger) Error(format string, v ...any)
{ log.Printf("ERROR: "+format, v...) }
Plugin
Plugin defines interface for plugins to register hooks on the App. Plugins can
provide a build step which is executed by the CLI before the application is
run and may also attach runtime hooks through Install.
type Plugin interface
Methods
Named
Named plugins expose a unique identifier used for deduplication.
Implementing this interface is optional.
type Named interface
Methods
PreBuilder
PreBuilder allows plugins to execute logic before the CLI build step.
Implementing this interface is optional.
type PreBuilder interface
Methods
PostBuilder
PostBuilder allows plugins to execute logic after the CLI build step.
Implementing this interface is optional.
type PostBuilder interface
Methods
Provider
Provider allows plugins to expose typed data to components via the DI container.
Implementing this interface is optional.
type Provider interface
Methods
App
App maintains registered hooks and exposes helper methods for plugins
to attach to framework events.
type App struct
Methods
RegisterRouter performs no work outside WASM.
Parameters
func (*App) RegisterRouter(func(string))
{}
RegisterStore performs no work outside WASM.
Parameters
func (*App) RegisterStore(func(module, store, key string, value any))
{}
RegisterLifecycle performs no work outside WASM.
Parameters
func (*App) RegisterLifecycle(func(Component), func(Component))
{}
RegisterTemplate performs no work outside WASM.
Parameters
func (*App) RegisterTemplate(func(componentID, html string))
{}
RegisterRTMLVar performs no work outside WASM.
Parameters
func (*App) RegisterRTMLVar(string, string, any)
{}
HasPlugin reports false outside WASM.
Parameters
Returns
func (*App) HasPlugin(string) bool
{ return false }
RegisterRouter adds a router navigation hook.
Parameters
func (*App) RegisterRouter(fn func(string))
{
a.routerHooks = append(a.routerHooks, fn)
}
RegisterStore adds a store mutation hook.
Parameters
func (*App) RegisterStore(fn func(module, store, key string, value any))
{
a.storeHooks = append(a.storeHooks, fn)
}
RegisterTemplate adds a template render hook.
Parameters
func (*App) RegisterTemplate(fn func(componentID, html string))
{
a.templateHooks = append(a.templateHooks, fn)
}
RegisterLifecycle adds hooks for component mount and unmount.
Parameters
func (*App) RegisterLifecycle(mount, unmount func(Component))
{
if mount != nil {
a.mountHooks = append(a.mountHooks, mount)
}
if unmount != nil {
a.unmountHooks = append(a.unmountHooks, unmount)
}
}
RegisterRTMLVar registers a value that can be referenced from RTML as {plugin:NAME.VAR}.
Parameters
func (*App) RegisterRTMLVar(plugin, name string, val any)
{
if a.pluginVars == nil {
a.pluginVars = make(map[string]map[string]any)
}
if _, ok := a.pluginVars[plugin]; !ok {
a.pluginVars[plugin] = make(map[string]any)
}
a.pluginVars[plugin][name] = val
}
HasPlugin reports whether a plugin with the given name is installed.
Parameters
Returns
func (*App) HasPlugin(name string) bool
{
if a.plugins == nil {
return false
}
_, ok := a.plugins[name]
return ok
}
Fields
| Name | Type | Description |
|---|---|---|
| pluginVars | map[string]map[string]any | |
| plugins | map[string]Plugin | |
| provides | map[string]any |
hooks
type hooks struct
Fields
| Name | Type | Description |
|---|---|---|
| routerHooks | []func(string) | |
| storeHooks | []func(module, store, key string, value any) | |
| templateHooks | []func(componentID, html string) | |
| mountHooks | []func(Component) | |
| unmountHooks | []func(Component) |
newApp
newApp creates an App with initialized hook storage.
Returns
func newApp() *App
{
return &App{hooks: &hooks{}, pluginVars: make(map[string]map[string]any), plugins: make(map[string]Plugin), provides: make(map[string]any)}
}
getRTMLVar
getRTMLVar retrieves a registered plugin variable.
Parameters
Returns
func getRTMLVar(plugin, name string) (any, bool)
{
if app.pluginVars == nil {
return nil, false
}
if vars, ok := app.pluginVars[plugin]; ok {
v, ok := vars[name]
return v, ok
}
return nil, false
}
RegisterPluginVar
RegisterPluginVar is a convenience wrapper for plugins to expose variables.
Parameters
func RegisterPluginVar(plugin, name string, val any)
{
app.RegisterRTMLVar(plugin, name, val)
}
RegisterPlugin
RegisterPlugin registers a plugin and allows it to add hooks. If the plugin
implements Named and has already been installed, it is skipped.
Parameters
func RegisterPlugin(p Plugin)
{
if n, ok := p.(Named); ok {
if app.HasPlugin(n.Name()) {
return
}
if app.plugins == nil {
app.plugins = make(map[string]Plugin)
}
app.plugins[n.Name()] = p
}
if r, ok := p.(Requires); ok {
for _, dep := range r.Requires() {
if dn, ok := dep.(Named); ok {
if app.HasPlugin(dn.Name()) {
continue
}
}
RegisterPlugin(dep)
}
}
if o, ok := p.(Optional); ok {
for _, dep := range o.Optional() {
if dn, ok := dep.(Named); ok {
if app.HasPlugin(dn.Name()) {
continue
}
}
RegisterPlugin(dep)
}
}
p.Install(app)
if prov, ok := p.(Provider); ok {
for k, v := range prov.Provide() {
app.provides[k] = v
}
}
}
Uses
GetProvider
GetProvider retrieves a value provided by a plugin via its Provider interface.
Parameters
Returns
func GetProvider(key string) (any, bool)
{
v, ok := app.provides[key]
return v, ok
}
TriggerRouter
TriggerRouter invokes router hooks with the given path.
Parameters
func TriggerRouter(path string)
{
for _, h := range app.routerHooks {
h(path)
}
}
OnTemplate
OnTemplate registers a function called with the rendered HTML every time a
component paints, the hook for work that must follow a render it does not
own (the router outlet repainting itself inside a re-rendered shell).
Parameters
func OnTemplate(fn func(componentID, html string))
{
app.templateHooks = append(app.templateHooks, fn)
}
TriggerStore
TriggerStore invokes store hooks for a mutation.
Parameters
func TriggerStore(module, store, key string, value any)
{
for _, h := range app.storeHooks {
h(module, store, key, value)
}
}
TriggerTemplate
TriggerTemplate invokes template hooks with rendered HTML for a component.
Parameters
func TriggerTemplate(componentID, html string)
{
for _, h := range app.templateHooks {
h(componentID, html)
}
}
TriggerMount
TriggerMount invokes mount lifecycle hooks.
Parameters
func TriggerMount(c Component)
{
for _, h := range app.mountHooks {
h(c)
}
}
Uses
TriggerUnmount
TriggerUnmount invokes unmount lifecycle hooks.
Parameters
func TriggerUnmount(c Component)
{
for _, h := range app.unmountHooks {
h(c)
}
}
Uses
init
func init()
{
state.StoreHook = TriggerStore
dom.TemplateHook = TriggerTemplate
}
TestDuplicateConditionsKeepTheirOwnContent
Two @if blocks carrying the same condition are two independent blocks. They
used to hash to the same id, so they shared one content entry and the patch
wrote the second block’s markup into the first one.
Parameters
func TestDuplicateConditionsKeepTheirOwnContent(t *testing.T)
{
store := state.NewStore("dupcond", state.WithModule("app"))
store.Set("chrome", "on")
tpl := []byte(`<root>
@if:store:app.dupcond.chrome == "on"
<aside data-first>first</aside>
@endif
<main>body</main>
@if:store:app.dupcond.chrome == "on"
<header data-second>second</header>
@endif
</root>`)
c := NewHTMLComponent("DupCond", tpl, nil)
c.SetComponent(c)
c.Init(nil)
html := c.Render()
if !strings.Contains(html, "data-first") || !strings.Contains(html, "data-second") {
t.Fatalf("both blocks should render, got: %s", html)
}
ids := map[string]int{}
for _, part := range strings.Split(html, `data-condition="`)[1:] {
ids[part[:strings.Index(part, `"`)]]++
}
if len(ids) != 2 {
t.Fatalf("expected two distinct condition ids, got %v", ids)
}
for id, n := range ids {
if n != 1 {
t.Fatalf("condition id %s used %d times", id, n)
}
}
first := strings.Index(html, "data-first")
second := strings.Index(html, "data-second")
if first > second {
t.Fatalf("blocks rendered out of order: %s", html)
}
}
TestDuplicateConditionIDsAreStableAcrossRenders
Re-rendering has to hand every block the same id it had before, or a patch
after a store change lands on the wrong node.
Parameters
func TestDuplicateConditionIDsAreStableAcrossRenders(t *testing.T)
{
store := state.NewStore("dupcond2", state.WithModule("app"))
store.Set("chrome", "on")
tpl := []byte(`<root>
@if:store:app.dupcond2.chrome == "on"
<aside>a</aside>
@endif
@if:store:app.dupcond2.chrome == "on"
<header>b</header>
@endif
</root>`)
c := NewHTMLComponent("DupCond2", tpl, nil)
c.SetComponent(c)
c.Init(nil)
ids := func() []string {
html := c.RenderFresh()
var out []string
for _, part := range strings.Split(html, `data-condition="`)[1:] {
out = append(out, part[:strings.Index(part, `"`)])
}
return out
}
before := ids()
after := ids()
if len(before) != 2 || len(after) != 2 {
t.Fatalf("expected two blocks per render, got %v and %v", before, after)
}
for i := range before {
if before[i] != after[i] {
t.Fatalf("condition id %d changed between renders: %s -> %s", i, before[i], after[i])
}
}
}
TestConditionOnStoreReactsWithoutOtherBindings
A component whose only reference to a store is an @if condition still has to
react to that key: without a subscription it renders once and freezes.
Parameters
func TestConditionOnStoreReactsWithoutOtherBindings(t *testing.T)
{
store := state.NewStore("condonly", state.WithModule("app"))
store.Set("chrome", "on")
defer state.GlobalStoreManager.UnregisterStore("app", "condonly")
// UpdateDOM resolves an unmounted component to #app, so the page needs one
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
tpl := []byte(`<root>
@if:store:app.condonly.chrome == "on"
<span id="chrome-block">visible</span>
@endif
</root>`)
c := NewHTMLComponent("CondOnly", tpl, nil)
c.SetComponent(c)
c.Init(nil)
dom.UpdateDOM(c.GetID(), c.Render())
c.Mount()
defer c.Unmount()
if !strings.Contains(dom.ComponentRoot(c.GetID()).HTML(), "chrome-block") {
t.Fatal("condition did not render the true branch")
}
store.Set("chrome", "off")
waitForRenderFlush()
if html := dom.ComponentRoot(c.GetID()).HTML(); strings.Contains(html, "chrome-block") {
t.Fatalf("condition did not react to the store change: %s", html)
}
store.Set("chrome", "on")
waitForRenderFlush()
if html := dom.ComponentRoot(c.GetID()).HTML(); !strings.Contains(html, "chrome-block") {
t.Fatalf("condition did not come back: %s", html)
}
}
TestNamedSlotExtraction
Parameters
func TestNamedSlotExtraction(t *testing.T)
{
childTpl := []byte("<root>@slot:avatar<div>default</div>@endslot</root>")
parentTpl := []byte("<root>@slot:child.avatar<img src=\"pic.png\"/>@endslot@include:child</root>")
store := state.NewStore("test")
parent := NewHTMLComponent("Parent", parentTpl, nil)
parent.Init(store)
child := NewHTMLComponent("Child", childTpl, nil)
// child Init will be called via AddDependency
parent.AddDependency("child", child)
html := parent.Render()
if strings.Contains(html, ".avatar") {
t.Fatalf("slot placeholder not removed: %s", html)
}
if !strings.Contains(html, "pic.png") {
t.Fatalf("slot content not injected: %s", html)
}
}
TestIncludePlaceholderPrefixCollision
Parameters
func TestIncludePlaceholderPrefixCollision(t *testing.T)
{
childTpl := []byte("<root>@slot:avatar<div>fallback-avatar</div>@endslot<div>@slot<p>fallback-details</p>@endslot</div></root>")
parentTpl := []byte("<root>@slot:card.avatar<img/>@endslot@slot:card<p>details</p>@endslot@include:card@include:cardFallback</root>")
store := state.NewStore("test2")
parent := NewHTMLComponent("Parent2", parentTpl, nil)
parent.Init(store)
card := NewHTMLComponent("Child", childTpl, nil)
fallback := NewHTMLComponent("Child", childTpl, nil)
parent.AddDependency("card", card)
parent.AddDependency("cardFallback", fallback)
html := parent.Render()
if strings.Count(html, "<img/>") != 1 {
t.Fatalf("expected one image only: %s", html)
}
if !strings.Contains(html, "fallback-avatar") || !strings.Contains(html, "fallback-details") {
t.Fatalf("fallback content missing: %s", html)
}
}
TestComponentInstancesHaveDistinctIDs
Parameters
func TestComponentInstancesHaveDistinctIDs(t *testing.T)
{
first := NewHTMLComponent("Repeated", []byte("<root></root>"), nil)
second := NewHTMLComponent("Repeated", []byte("<root></root>"), nil)
if first.ID == second.ID {
t.Fatalf("component instances share ID %q", first.ID)
}
}
SetDevMode
SetDevMode toggles development mode features.
Parameters
func SetDevMode(enabled bool)
{
DevMode = enabled
}
RegisterComponent
RegisterComponent registers a component constructor under the provided name.
When a template references the name via rt-is, the constructor will be
invoked to create a new component instance at render time. It returns an
error if a component with the same name is already registered and logs a
warning.
Parameters
Returns
func RegisterComponent(name string, constructor func() Component) error
{
componentRegistryMu.Lock()
defer componentRegistryMu.Unlock()
if _, exists := ComponentRegistry[name]; exists {
Log().Warn("component %s already registered", name)
return fmt.Errorf("component %s already registered", name)
}
ComponentRegistry[name] = constructor
return nil
}
Uses
LoadComponent
LoadComponent retrieves a component by name using the registry. If no
component is registered under that name, nil is returned.
Parameters
Returns
func LoadComponent(name string) Component
{
componentRegistryMu.RLock()
ctor, ok := ComponentRegistry[name]
componentRegistryMu.RUnlock()
if ok {
return ctor()
}
return nil
}
Uses
NewComponent
NewComponent creates an HTMLComponent initialized with the provided
template and props. It sets itself as the underlying component and
performs initialization with the default store.
Parameters
Returns
func NewComponent(name string, templateFS []byte, props map[string]any) *HTMLComponent
{
c := NewHTMLComponent(name, templateFS, props)
c.SetComponent(c)
c.Init(nil)
return c
}
NewComponentWith
NewComponentWith creates an HTMLComponent and binds it to the given
component implementation. This is useful when embedding HTMLComponent
inside another struct to override lifecycle hooks.
Parameters
Returns
func NewComponentWith[T Component](name string, templateFS []byte, props map[string]any, self T) *HTMLComponent
{
c := NewHTMLComponent(name, templateFS, props)
if any(self) != nil {
c.SetComponent(self)
} else {
c.SetComponent(c)
}
c.Init(nil)
return c
}
reportScopeError
Parameters
func reportScopeError(err any)
{
log.Printf("component scope cleanup: %v", err)
}
Suspense
Suspense renders a fallback while its render function reports pending work.
type Suspense struct
Methods
Render executes the render function and shows the fallback until it resolves.
Returns
func (*Suspense) Render() string
{
s.last = s.renderHTML()
return s.last
}
Returns
func (*Suspense) renderHTML() string
{
content := s.fallback
if s.render == nil {
return `<root data-component-id="` + s.id + `">` + content + `</root>`
}
rendered, err := s.render()
switch {
case errors.Is(err, http.ErrPending), errors.Is(err, state.ErrResourcePending):
case err != nil:
content = html.EscapeString(err.Error())
default:
content = rendered
}
return `<root data-component-id="` + s.id + `">` + content + `</root>`
}
Mount subscribes to every reactive value read by the render function.
func (*Suspense) Mount()
{
s.mounted = true
if s.stop != nil {
s.stop()
}
s.stop = state.Effect(func() func() {
next := s.renderHTML()
if s.mounted && next != s.last {
s.pending = next
requestScheduledRender(renderJob{
id: s.id,
depth: mountedComponentDepth(s.id),
active: func() bool {
return s.mounted
},
render: func() {
if s.pending == s.last {
return
}
s.last = s.pending
dom.UpdateMountedDOM(s.id, s.last)
},
})
}
return nil
})
}
Unmount releases the reactive render subscription.
func (*Suspense) Unmount()
{
s.mounted = false
cancelComponentRender(s.id)
if s.stop != nil {
s.stop()
s.stop = nil
}
}
GetName returns the component name.
Returns
func (*Suspense) GetName() string
{ return "Suspense" }
GetID returns this Suspense instance ID.
Returns
func (*Suspense) GetID() string
{ return s.id }
SetSlots is a no-op since Suspense does not use slots.
Parameters
func (*Suspense) SetSlots(map[string]any)
{}
IsMounted reports whether Suspense is mounted.
Returns
func (*Suspense) IsMounted() bool
{ return s.mounted }
OnParams is a no-op since Suspense does not consume route parameters.
Parameters
func (*Suspense) OnParams(map[string]string)
{}
Fields
| Name | Type | Description |
|---|---|---|
| render | func() (string, error) | |
| fallback | string | |
| id | string | |
| mounted | bool | |
| last | string | |
| pending | string | |
| stop | func() |
NewSuspense
NewSuspense creates a Suspense component with the given render function and fallback HTML.
Parameters
Returns
func NewSuspense(render func() (string, error), fallback string) *Suspense
{
return &Suspense{
render: render,
fallback: fallback,
id: generateComponentID("Suspense", nil),
}
}