core API

core

package

API reference for the core package.

F
function

MustRegisterComponent

MustRegisterComponent registers a component constructor under the provided name
and panics if the component is already registered.

Parameters

name
string
ctor
func() Component
core/component_mustregister.go:5-9
func MustRegisterComponent(name string, ctor func() Component)

{
	if err := RegisterComponent(name, ctor); err != nil {
		panic(err)
	}
}
S
struct

ComponentStats

ComponentStats is a stub for non-wasm builds.

core/html_component_stats_stub.go:8-14
type ComponentStats struct

Fields

Name Type Description
RenderCount int
TotalRender time.Duration
LastRender time.Duration
AverageRender time.Duration
Timeline []ComponentTimelineEntry
S
struct

ComponentTimelineEntry

ComponentTimelineEntry is a stub for non-wasm builds.

core/html_component_stats_stub.go:17-21
type ComponentTimelineEntry struct

Fields

Name Type Description
Kind string
Timestamp time.Time
Duration time.Duration
S
struct

renderJob

core/render_scheduler.go:19-24
type renderJob struct

Fields

Name Type Description
id string
depth int
active func() bool
render func()
F
function

requestScheduledRender

Parameters

job
core/render_scheduler.go:46-71
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)
}
F
function

cancelComponentRender

Parameters

componentID
string
core/render_scheduler.go:73-77
func cancelComponentRender(componentID string)

{
	componentRenderScheduler.Lock()
	delete(componentRenderScheduler.pending, componentID)
	componentRenderScheduler.Unlock()
}
F
function

flushComponentRenders

core/render_scheduler.go:79-103
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()
	}
}
F
function

componentDepth

Parameters

component

Returns

int
core/render_scheduler.go:105-112
func componentDepth(component *HTMLComponent) int

{
	depth := 0
	for component != nil && component.parent != nil {
		depth++
		component = component.parent
	}
	return depth
}
F
function

mountedComponentDepth

Parameters

componentID
string

Returns

int
core/render_scheduler.go:114-126
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
}
F
function

renderGolden

Parameters

name
string
tpl
string
props
map[string]any

Returns

string
core/rtml_golden_test.go:18-23
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
}
F
function

expectGolden

Parameters

got
string
want
string
core/rtml_golden_test.go:25-30
func expectGolden(t *testing.T, got, want string)

{
	t.Helper()
	if got != want {
		t.Fatalf("golden mismatch:\n got: %q\nwant: %q", got, want)
	}
}
F
function

TestGoldenStoreDirectives

Parameters

core/rtml_golden_test.go:32-41
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">&lt;i&gt;x&lt;/i&gt;</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)
}
F
function

TestGoldenSignalDirectives

Parameters

core/rtml_golden_test.go:43-50
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">&lt;b&gt;s&lt;/b&gt;</span></p><input value="@signal:v:w"/></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenExprDirective

Parameters

core/rtml_golden_test.go:52-58
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)
}
F
function

TestGoldenClassExprDirective

Parameters

core/rtml_golden_test.go:60-66
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)
}
F
function

TestGoldenPropDirectives

Parameters

core/rtml_golden_test.go:68-74
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>&lt;u&gt;p&lt;/u&gt;</p><p>&lt;u&gt;p&lt;/u&gt;</p><p><u>m</u></p><p>@prop:missing</p></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenIncludeDirective

Parameters

core/rtml_golden_test.go:76-87
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)
}
F
function

TestGoldenSlotDirective

Parameters

core/rtml_golden_test.go:89-106
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)
}
F
function

TestGoldenForRangeDirective

Parameters

core/rtml_golden_test.go:108-114
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)
}
F
function

TestGoldenForSliceDirective

Parameters

core/rtml_golden_test.go:116-124
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">&lt;b&gt;a&lt;/b&gt;</li></ul></root>
`, c.ID, c.ID, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenForMapDirective

Parameters

core/rtml_golden_test.go:126-134
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=&lt;b&gt;v&lt;/b&gt;</li></ul></root>
`, c.ID, c.ID, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenConditionalDirective

Parameters

core/rtml_golden_test.go:136-146
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)
}
F
function

TestGoldenEventDirectives

Parameters

core/rtml_golden_test.go:148-154
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)
}
F
function

TestGoldenRtIsDirective

Parameters

core/rtml_golden_test.go:156-169
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)
}
F
function

TestGoldenConstructorDirectives

Parameters

core/rtml_golden_test.go:171-177
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)
}
F
function

TestGoldenPluginDirectives

Parameters

core/rtml_golden_test.go:179-186
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)
}
F
function

TestGoldenHostDirectives

Parameters

core/rtml_golden_test.go:188-197
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)
}
F
function

TestElseIfRendering

Tests complex conditional scenarios including @else-if and nested blocks.

Parameters

core/rtml_test.go:15-33
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)
	}
}
F
function

TestNestedConditionals

Parameters

core/rtml_test.go:35-59
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)
	}
}
F
function

TestReplaceConstructors

Tests constructor decorators for refs and keyed lists.

Parameters

core/rtml_test.go:62-74
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)
	}
}
F
function

TestPluginPlaceholders

Tests plugin placeholders for variables, commands and constructors.

Parameters

core/rtml_test.go:77-93
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)
	}
}
F
function

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

core/rtml_test.go:97-108
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)
	}
}
F
function

TestForFieldsEscapedByDefault

Substituted values are HTML-escaped by default; @rawprop opts into markup.

Parameters

core/rtml_test.go:111-126
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, "&lt;img src=x&gt;") {
		t.Fatalf("field not escaped: %s", html)
	}
	if !strings.Contains(html, "<b>ok</b>") {
		t.Fatalf("rawprop escaped: %s", html)
	}
}
F
function

TestSignalEscapedByDefault

@signal values are escaped by default: a signal carrying user-supplied
markup must render as text, not execute as HTML.

Parameters

core/rtml_test.go:130-142
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, "&lt;img src=x onerror=alert(1)&gt;") {
		t.Fatalf("signal not escaped: %s", html)
	}
	if strings.Contains(html, "<img") {
		t.Fatalf("signal injected markup: %s", html)
	}
}
F
function

TestExprEscapedByDefault

@expr output is escaped by default, matching @store/@prop policy.

Parameters

core/rtml_test.go:145-156
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, "&lt;b&gt;bad&lt;/b&gt;") {
		t.Fatalf("expr not escaped: %s", html)
	}
	if strings.Contains(html, "<b>bad</b>") {
		t.Fatalf("expr injected markup: %s", html)
	}
}
F
function

TestSignalUpdateUsesTextContent

Signal updates go through textContent so later values cannot inject HTML.

Parameters

core/rtml_test.go:159-179
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, "&lt;img") {
		t.Fatalf("signal update not applied as text: %s", inner)
	}
}
F
function

TestCurlyPropEscapedByDefault

{{prop}} substitutions are escaped by default like @prop; @rawprop stays
the trusted markup escape hatch.

Parameters

core/rtml_test.go:183-197
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, "&lt;script&gt;alert(1)&lt;/script&gt;") {
		t.Fatalf("curly prop not escaped: %s", html)
	}
	if !strings.Contains(html, "<b>ok</b>") {
		t.Fatalf("rawprop escaped: %s", html)
	}
}
F
function

TestForMapFieldsEscapedByDefault

@for over a map collection escapes keys, scalar values and map fields just
like the slice path; @rawprop opts into trusted markup.

Parameters

core/rtml_test.go:201-221
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, "&lt;key&gt;") {
		t.Fatalf("map key not escaped: %s", html)
	}
	if !strings.Contains(html, "&lt;img src=x&gt;") {
		t.Fatalf("map field not escaped: %s", html)
	}
	if !strings.Contains(html, "<b>ok</b>") {
		t.Fatalf("rawprop escaped: %s", html)
	}
}
F
function

TestForMapScalarsEscapedByDefault

@for over a map of scalars escapes values by default.

Parameters

core/rtml_test.go:224-236
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, "&lt;i&gt;x&lt;/i&gt;") {
		t.Fatalf("map scalar not escaped: %s", html)
	}
}
F
function

TestStoreEscapedByDefault

@store values are escaped by default; @rawstore injects trusted markup.

Parameters

core/rtml_test.go:239-253
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, "&lt;i&gt;x&lt;/i&gt;") {
		t.Fatalf("store not escaped: %s", html)
	}
	if !strings.Contains(html, "<i>y</i>") {
		t.Fatalf("rawstore escaped: %s", html)
	}
}
F
function

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

core/rtml_test.go:259-286
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)
	}
}
F
function

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

core/rtml_test.go:290-306
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")
	}
}
F
function

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

core/rtml_test.go:311-333
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")
	}
}
F
function

Version

Version returns the framework version for this build.

Returns

string
core/version.go:12-20
func Version() string

{
	if info, ok := debug.ReadBuildInfo(); ok {
		v := info.Main.Version
		if v != "" && v != "(devel)" {
			return v
		}
	}
	return version
}
S
struct

Portal

Portal renders a child into a DOM target outside its component tree.

core/builtin_components.go:15-21
type Portal struct

Methods

Render
Method

Render returns the portal anchor markup.

Returns

string
func (*Portal) Render() string
{
	return `<root data-component-id="` + portal.id + `"><template data-portal-anchor></template></root>`
}
Mount
Method

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
Method

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
}
OnMount
Method

OnMount performs no additional work.

func (*Portal) OnMount()
{}
OnUnmount
Method

OnUnmount performs no additional work.

func (*Portal) OnUnmount()
{}
GetName
Method

GetName returns the portal component name.

Returns

string
func (*Portal) GetName() string
{
	return "Portal"
}
GetID
Method

GetID returns the portal component ID.

Returns

string
func (*Portal) GetID() string
{ return portal.id }
SetSlots
Method

SetSlots forwards slots to the child component.

Parameters

slots map[string]any
func (*Portal) SetSlots(slots map[string]any)
{
	if portal.child != nil {
		portal.child.SetSlots(slots)
	}
}
IsMounted
Method

IsMounted reports whether the portal is mounted.

Returns

bool
func (*Portal) IsMounted() bool
{ return portal.mounted }
OnParams
Method

OnParams forwards route parameters to the child.

Parameters

params map[string]string
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
F
function

NewPortal

NewPortal creates a portal targeting a CSS selector.

Parameters

selector
string
child

Returns

core/builtin_components.go:24-30
func NewPortal(selector string, child Component) *Portal

{
	return &Portal{
		id:       generateComponentID("Portal", map[string]any{"target": selector}),
		selector: selector,
		child:    child,
	}
}
I
interface

KeepAliveAware

KeepAliveAware receives activation events without being unmounted.

core/builtin_components.go:100-103
type KeepAliveAware interface

Methods

OnActivate
Method
func OnActivate(...)
OnDeactivate
Method
func OnDeactivate(...)
S
struct

KeepAlive

KeepAlive preserves a child’s DOM and component state across route swaps.

core/builtin_components.go:106-114
type KeepAlive struct

Methods

Render
Method

Render returns the wrapper markup for the cached child.

Returns

string
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
Method

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
Method

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
Method

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")
	}
}
OnMount
Method

OnMount handles the component lifecycle callback.

func (*KeepAlive) OnMount()
{}
OnUnmount
Method

OnUnmount handles the component lifecycle callback.

func (*KeepAlive) OnUnmount()
{}
GetName
Method

GetName returns the component name.

Returns

string
func (*KeepAlive) GetName() string
{
	return "KeepAlive"
}
GetID
Method

GetID returns the component identifier.

Returns

string
func (*KeepAlive) GetID() string
{ return keep.id }
SetSlots
Method

SetSlots forwards slots to the cached child.

Parameters

slots map[string]any
func (*KeepAlive) SetSlots(slots map[string]any)
{
	if keep.child != nil {
		keep.child.SetSlots(slots)
	}
}
IsMounted
Method

IsMounted reports whether the component is mounted.

Returns

bool
func (*KeepAlive) IsMounted() bool
{ return keep.mounted }
OnParams
Method

OnParams forwards route parameters to the cached child.

Parameters

params map[string]string
func (*KeepAlive) OnParams(params map[string]string)
{
	if keep.child != nil {
		keep.child.OnParams(params)
	}
}

Fields

Name Type Description
id string
child Component
fragment js.Value
initialized bool
cached bool
mounted bool
disposed bool
F
function

NewKeepAlive

NewKeepAlive creates a state-preserving component wrapper.

Parameters

child

Returns

core/builtin_components.go:117-119
func NewKeepAlive(child Component) *KeepAlive

{
	return &KeepAlive{id: generateComponentID("KeepAlive", nil), child: child}
}
S
struct

TransitionConfig

TransitionConfig names the CSS classes used during enter and leave phases.

core/builtin_components.go:230-238
type TransitionConfig struct

Fields

Name Type Description
EnterFrom string
EnterActive string
EnterTo string
LeaveFrom string
LeaveActive string
LeaveTo string
Duration time.Duration
F
function

DefaultTransitionConfig

DefaultTransitionConfig returns class names compatible with plain CSS.

core/builtin_components.go:241-251
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,
	}
}
S
struct

Transition

Transition applies CSS enter and leave phases around a child component.

core/builtin_components.go:254-261
type Transition struct

Methods

Render
Method

Render returns the transition wrapper markup.

Returns

string
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
Method

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
Method

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
Method

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
}
OnMount
Method

OnMount handles the component lifecycle callback.

func (*Transition) OnMount()
{}
OnUnmount
Method

OnUnmount handles the component lifecycle callback.

func (*Transition) OnUnmount()
{}
GetName
Method

GetName returns the component name.

Returns

string
func (*Transition) GetName() string
{
	return "Transition"
}
GetID
Method

GetID returns the component identifier.

Returns

string
func (*Transition) GetID() string
{ return transition.id }
SetSlots
Method

SetSlots forwards slots to the child.

Parameters

slots map[string]any
func (*Transition) SetSlots(slots map[string]any)
{
	if transition.child != nil {
		transition.child.SetSlots(slots)
	}
}
IsMounted
Method

IsMounted reports whether the component is mounted.

Returns

bool
func (*Transition) IsMounted() bool
{ return transition.mounted }
OnParams
Method

OnParams forwards route parameters to the child.

Parameters

params map[string]string
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
F
function

NewTransition

NewTransition creates a CSS transition wrapper.

Parameters

Returns

core/builtin_components.go:264-292
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,
	}
}
F
function

addClasses

Parameters

element
groups
...string
core/builtin_components.go:414-420
func addClasses(element dom.Element, groups ...string)

{
	for _, group := range groups {
		for _, className := range strings.Fields(group) {
			element.AddClass(className)
		}
	}
}
F
function

removeClasses

Parameters

element
groups
...string
core/builtin_components.go:422-428
func removeClasses(element dom.Element, groups ...string)

{
	for _, group := range groups {
		for _, className := range strings.Fields(group) {
			element.RemoveClass(className)
		}
	}
}
F
function

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

core/dependency_condition_dom_test.go:17-66
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)
	}
}
F
function

TestComponentDOMHookLifecycle

Parameters

core/dom_hook_test.go:11-49
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)
	}
}
F
function

TestUnmountCleanupContinuesAfterLifecyclePanic

Parameters

core/dom_hook_test.go:51-72
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")
	}
}
F
function

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

err
any
context
string
core/error_overlay.go:18-20
func ShowErrorOverlay(err any, context string)

{
	globalOverlay.show(err, context)
}
S
struct

errorOverlay

core/error_overlay.go:22-26
type errorOverlay struct

Methods

show
Method

Parameters

err any
context string
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

errStr string
goStack string
context string
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
}
bindActions
Method

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
		}))
	}
}
buildMainHTML
Method

Parameters

errStr string
goStack string
context string

Returns

string
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 &middot; %s
    </div>
</div>
    `, cat, htmlEscape(errStr), hint,
		htmlEscape(goStack),
		htmlEscape(fmt.Sprintf("Error: %s\nContext: %s\n\n%s", errStr, context, goStack)),
		versionStr)
}

Parameters

n int
errStr string
goStack string
_ string

Returns

string
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))
}
categorize
Method

Parameters

err string

Returns

string
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"
	}
}
hintHTML
Method

Parameters

errStr string
_ string

Returns

string
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(&amp;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
F
function

htmlEscape

Parameters

s
string

Returns

string
core/error_overlay.go:241-247
func htmlEscape(s string) string

{
	s = strings.ReplaceAll(s, "&", "&amp;")
	s = strings.ReplaceAll(s, "<", "&lt;")
	s = strings.ReplaceAll(s, ">", "&gt;")
	s = strings.ReplaceAll(s, "\"", "&quot;")
	return s
}
F
function

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

core/host_component_test.go:10-23
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)
	}
}
F
function

TestHostComponentFieldFallback

Directly assigning the exported HostComponent field keeps working.

Parameters

core/host_component_test.go:26-33
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)
	}
}
S
struct

Scope

Scope owns work that must stop with a component.

core/scope.go:9-15
type Scope struct

Methods

Context
Method

Context is cancelled when the scope closes.

Returns

func (*Scope) Context() context.Context
{
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.ctx
}
Defer
Method

Defer registers cleanup work in last-in, first-out order.

Parameters

fn func()
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
Method

Go starts work with the scope context.

Parameters

fn func(context.Context)
func (*Scope) Go(fn func(context.Context))
{
	if fn == nil {
		return
	}
	go fn(s.Context())
}
Close
Method

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
Method

Closed reports whether Close has run.

Returns

bool
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
F
function

NewScope

NewScope creates an open lifecycle scope.

Returns

core/scope.go:18-21
func NewScope() *Scope

{
	ctx, cancel := context.WithCancel(context.Background())
	return &Scope{ctx: ctx, cancel: cancel}
}
F
function

TestSuspenseMountState

Parameters

core/suspense_component_test.go:16-29
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")
	}
}
F
function

TestSuspenseUpdatesWhenResourceResolves

Parameters

core/suspense_component_test.go:31-68
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)
	}
}
F
function

TestSuspenseCoalescesReactiveDOMUpdates

Parameters

core/suspense_component_test.go:70-105
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)
	}
}
S
struct
Implements: Plugin Named

namedTestPlugin

core/plugin_test.go:10-10
type namedTestPlugin struct

Methods

Build
Method

Parameters

Returns

error
func (*namedTestPlugin) Build(json.RawMessage) error
{ return nil }
Install
Method

Parameters

*App
func (*namedTestPlugin) Install(*App)
{ p.installed++ }
Name
Method

Returns

string
func (*namedTestPlugin) Name() string
{ return "named-test" }

Fields

Name Type Description
installed int
F
function

TestRegisterPlugin_dedup

Parameters

core/plugin_test.go:16-31
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")
	}
}
S
struct
Implements: Plugin Named

depPlugin

core/plugin_test.go:33-33
type depPlugin struct

Methods

Build
Method

Parameters

Returns

error
func (*depPlugin) Build(json.RawMessage) error
{ return nil }
Install
Method

Parameters

*App
func (*depPlugin) Install(*App)
{ p.installed++ }
Name
Method

Returns

string
func (*depPlugin) Name() string
{ return "dep" }

Fields

Name Type Description
installed int
S
struct
Implements: Requires Named

requiresPlugin

core/plugin_test.go:39-39
type requiresPlugin struct

Methods

Build
Method

Parameters

Returns

error
func (*requiresPlugin) Build(json.RawMessage) error
{ return nil }
Install
Method

Parameters

_ *App
func (*requiresPlugin) Install(_ *App)
{}
Name
Method

Returns

string
func (*requiresPlugin) Name() string
{ return "requires" }
Requires
Method

Returns

[]Plugin
func (*requiresPlugin) Requires() []Plugin
{ return []Plugin{p.dep} }

Fields

Name Type Description
dep *depPlugin
F
function

TestRegisterPlugin_requires

Parameters

core/plugin_test.go:46-57
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")
	}
}
S
struct
Implements: Optional Named

optionalPlugin

core/plugin_test.go:59-62
type optionalPlugin struct

Methods

Build
Method

Parameters

Returns

error
func (*optionalPlugin) Build(json.RawMessage) error
{ return nil }
Install
Method

Parameters

_ *App
func (*optionalPlugin) Install(_ *App)
{}
Name
Method

Returns

string
func (*optionalPlugin) Name() string
{ return "optional" }
Optional
Method

Returns

[]Plugin
func (*optionalPlugin) Optional() []Plugin
{
	if !p.enable {
		return nil
	}
	return []Plugin{p.dep}
}

Fields

Name Type Description
dep *depPlugin
enable bool
F
function

TestRegisterPlugin_optional

Parameters

core/plugin_test.go:74-90
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")
	}
}
S
struct

HTMLComponent

HTMLComponent is the non-WASM component placeholder.

core/dev_hot_reload_stub.go:6-10
type HTMLComponent struct

Methods

Stats
Method

Stats returns zeroed metrics on non-wasm builds.

Returns

func (*HTMLComponent) Stats() ComponentStats
{ return ComponentStats{} }
requestRender
Method

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
Method

Render returns no markup outside WASM.

Returns

string
func (*HTMLComponent) Render() string
{ return "" }
Mount
Method

Mount performs no work outside WASM.

func (*HTMLComponent) Mount()
{}
Unmount
Method

Unmount performs no work outside WASM.

func (*HTMLComponent) Unmount()
{}
OnMount
Method

OnMount performs no work outside WASM.

func (*HTMLComponent) OnMount()
{}
OnUnmount
Method

OnUnmount performs no work outside WASM.

func (*HTMLComponent) OnUnmount()
{}
GetName
Method

GetName returns the component name.

Returns

string
func (*HTMLComponent) GetName() string
{ return c.Name }
GetID
Method

GetID returns the component ID.

Returns

string
func (*HTMLComponent) GetID() string
{ return c.ID }
SetSlots
Method

SetSlots performs no work outside WASM.

Parameters

map[string]any
func (*HTMLComponent) SetSlots(map[string]any)
{}
Scope
Method

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
Method

Init attaches a state store and prepares the component template.

Parameters

store *state.Store
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
Method

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

string
func (*HTMLComponent) RenderFresh() string
{
	c.Invalidate()
	return c.Render()
}
Invalidate
Method

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
Method

Render evaluates the component template.

Returns

renderedTemplate string
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
}
recordRender
Method

Parameters

duration time.Duration
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
Method

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
Method

AddDependency attaches a child component to a template placeholder.

Parameters

placeholderName string
dep Component
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
Method

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
Method

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)
}
runLifecycle
Method

Parameters

phase string
fn func()
func (*HTMLComponent) runLifecycle(phase string, fn func())
{
	defer func() {
		if recovered := recover(); recovered != nil {
			ReportError(recovered, phase+": "+c.Name+" (ID: "+c.ID+")")
		}
	}()
	fn()
}
Scope
Method

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
Method

Effect registers a reactive effect that stops on unmount.

Parameters

fn func() func()
func (*HTMLComponent) Effect(fn func() func())
{
	c.Scope().Defer(state.Effect(fn))
}
DOMHook
Method

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
Method

On registers an event handler owned by this component instance.

Parameters

name string
fn func()
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
Method

GetName returns the component name.

Returns

string
func (*HTMLComponent) GetName() string
{
	return c.Name
}
GetID
Method

GetID returns the component identifier.

Returns

string
func (*HTMLComponent) GetID() string
{
	return c.ID
}
GetRef
Method

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

name string

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
Method

OnMount runs the configured mount callback.

func (*HTMLComponent) OnMount()
{
	if c.onMount != nil {
		c.onMount(c)
	}
}
OnUnmount
Method

OnUnmount runs the configured unmount callback.

func (*HTMLComponent) OnUnmount()
{
	if c.onUnmount != nil {
		c.onUnmount(c)
	}
	c.mounted = false
}
IsMounted
Method

IsMounted reports whether the component is mounted.

Returns

bool
func (*HTMLComponent) IsMounted() bool
{
	return c.mounted
}
OnParams
Method

OnParams runs the configured route-parameter callback.

Parameters

params map[string]string
func (*HTMLComponent) OnParams(params map[string]string)
{
	if c.onParams != nil {
		c.onParams(c, params)
	}
}
SetOnParams
Method

SetOnParams configures the route-parameter callback.

Parameters

fn func(*HTMLComponent, map[string]string)
func (*HTMLComponent) SetOnParams(fn func(*HTMLComponent, map[string]string))
{
	c.onParams = fn
}
SetOnMount
Method

SetOnMount configures the mount callback.

Parameters

fn func(*HTMLComponent)
func (*HTMLComponent) SetOnMount(fn func(*HTMLComponent))
{
	c.onMount = fn
}
SetOnUnmount
Method

SetOnUnmount configures the unmount callback.

Parameters

fn func(*HTMLComponent)
func (*HTMLComponent) SetOnUnmount(fn func(*HTMLComponent))
{
	c.onUnmount = fn
}
WithLifecycle
Method

WithLifecycle configures mount and unmount callbacks.

Parameters

onMount func(*HTMLComponent)
onUnmount func(*HTMLComponent)

Returns

func (*HTMLComponent) WithLifecycle(onMount, onUnmount func(*HTMLComponent)) *HTMLComponent
{
	c.onMount = onMount
	c.onUnmount = onUnmount
	return c
}
SetComponent
Method

SetComponent attaches the component lifecycle implementation.

Parameters

component Component
func (*HTMLComponent) SetComponent(component Component)
{
	c.component = component
}
SetSlots
Method

SetSlots merges named slot content into the component.

Parameters

slots map[string]any
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
Method

Provide stores a value on this component so that descendants can retrieve it with Inject. It creates the map on first use.

Parameters

key string
val any
func (*HTMLComponent) Provide(key string, val any)
{
	if c.provides == nil {
		c.provides = make(map[string]any)
	}
	c.provides[key] = val
}
Inject
Method

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

key string

Returns

any
bool
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

params map[string]string
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

name string
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

[]string
func (*HTMLComponent) hostComponentNames() []string
{
	if len(c.hostComponents) > 0 {
		return c.hostComponents
	}
	if c.HostComponent != "" {
		return []string{c.HostComponent}
	}
	return nil
}
cacheKey
Method

Returns

string
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
T
type

ErrorSink

ErrorSink receives a recovered error together with a short human-readable
context such as “Render: Home (ID: abc)”.

core/errors.go:25-25
type ErrorSink func(err any, context string)
F
function

OnError

OnError registers a sink invoked for every error reported by the runtime.
It returns a function that removes the sink.

Parameters

Returns

func()
core/errors.go:34-49
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()
	}
}
F
function

ReportError

ReportError delivers err to every registered sink and to the developer
overlay. All recovery paths in the framework funnel through here.

Parameters

err
any
context
string
core/errors.go:53-78
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)
	}()
}
F
function

init

Delegated event handlers recover panics inside the dom package; route them
into the same pipeline as every other capture point.

core/errors.go:82-92
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)
	}
}
S
struct

recoveredPanic

core/errors.go:94-97
type recoveredPanic struct

Methods

Error
Method

Returns

string
func (recoveredPanic) Error() string
{
	if err, ok := p.value.(error); ok {
		return err.Error()
	}
	return fmt.Sprint(p.value)
}
Stack
Method

Returns

[]byte
func (recoveredPanic) Stack() []byte
{
	if len(p.stack) == 0 {
		return debug.Stack()
	}
	return p.stack
}

Fields

Name Type Description
value any
stack []byte
S
struct

errPipeComponent

core/errors_test.go:10-10
type errPipeComponent struct

Methods

Render
Method

Returns

string
func (*errPipeComponent) Render() string
{ panic("boom") }
F
function

newErrPipeComponent

Returns

core/errors_test.go:12-17
func newErrPipeComponent() *errPipeComponent

{
	c := &errPipeComponent{HTMLComponent: NewHTMLComponent("Boom", []byte("<root><div></div></root>"), nil)}
	c.SetComponent(c)
	c.Init(nil)
	return c
}
F
function

TestReportErrorFansOutToSinks

Parameters

core/errors_test.go:21-47
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)
	}
}
F
function

resolveNestedKey

Parameters

m
map[string]any
key
string

Returns

any
bool
core/rtml_for.go:15-29
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
}
F
function

replaceForPlaceholders

Parameters

template
string

Returns

string
core/rtml_for.go:31-138
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
	})
}
F
function

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

loopID
string

Returns

bool
core/rtml_for.go:143-177
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
}
F
function

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

loopID
string

Returns

string
core/rtml_for.go:182-184
func forAnchor(loopID string) string

{
	return fmt.Sprintf(`<template data-for-anchor="%s"></template>`, loopID)
}
F
function

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

content
string
key
any
loopID
string

Returns

string
core/rtml_for.go:189-203
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]:]
}
F
function

expandForRows

expandForRows renders the loop body once per item. It reports false when the
collection is not a shape the loop understands.

Parameters

aliases
[]string
loopContent
string
collection
any
loopID
string

Returns

string
bool
core/rtml_for.go:207-265
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
	}
}
F
function

substituteItemFields

substituteItemFields fills the @prop / @rawprop field references of one item.

Parameters

content
string
alias
string
item
map[string]any

Returns

string
core/rtml_for.go:268-289
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
	})
}
F
function

mountForComponent

Parameters

name
string
tpl
[]byte

Returns

core/rtml_for_patch_test.go:14-26
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
}
F
function

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

core/rtml_for_patch_test.go:30-61
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")
	}
}
F
function

TestForPatchRendersEveryRow

The patched rows have to match what a full render would have produced.

Parameters

core/rtml_for_patch_test.go:64-100
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)
	}
}
F
function

TestUnifiedReconcilerPreservesExplicitlyKeyedRows

Parameters

core/rtml_for_patch_test.go:102-138
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")
	}
}
F
function

TestUnifiedReconcilerKeepsFocusedInputOutsideLoop

Parameters

core/rtml_for_patch_test.go:140-171
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())
	}
}
F
function

TestHiddenLoopWaitsForBranchMount

Parameters

core/rtml_for_patch_test.go:173-207
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())
	}
}
F
function

TestForPatchRebindsInputsOnce

Parameters

core/rtml_for_patch_test.go:209-256
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")
}
F
function

TestStoreInputDefersWritesDuringIMEComposition

Parameters

core/rtml_for_patch_test.go:258-285
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)
	}
}
F
function

expectOneStoreSet

Parameters

sets
<-chan string
want
string
core/rtml_for_patch_test.go:287-302
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):
	}
}
F
function

TestForPatchRebindsSignalInputsOnce

Parameters

core/rtml_for_patch_test.go:304-345
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")
}
F
function

TestLoopInHiddenConditionalBranchIgnoresUnmountedComponent

Parameters

core/rtml_for_patch_test.go:347-380
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")
	}
}
F
function

expectOneSignalSet

Parameters

sets
<-chan string
want
string
core/rtml_for_patch_test.go:382-397
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):
	}
}
F
function

reportScopeError

Parameters

err
any
core/scope_error_wasm.go:5-7
func reportScopeError(err any)

{
	ReportError(err, "component scope cleanup")
}
F
function

ensureAppRoot

Returns

core/builtin_components_test.go:13-22
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
}
F
function

testHTMLComponent

Parameters

name
string
template
string

Returns

core/builtin_components_test.go:24-29
func testHTMLComponent(name, template string) *HTMLComponent

{
	component := NewHTMLComponent(name, []byte(template), nil)
	component.SetComponent(component)
	component.Init(nil)
	return component
}
F
function

TestPortalMountsOutsideComponentTree

Parameters

core/builtin_components_test.go:31-53
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")
	}
}
F
function

TestKeepAlivePreservesDOMAndState

Parameters

core/builtin_components_test.go:55-80
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")
	}
}
F
function

TestTransitionRunsEnterAndLeavePhases

Parameters

core/builtin_components_test.go:82-109
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)
	}
}
F
function

TestTransitionCanRemountDuringLeave

Parameters

core/builtin_components_test.go:111-128
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()
}
F
function

TestForRendersComponentList

Parameters

core/for_component_test.go:12-27
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)
	}
}
F
function

TestForRendersMapFields

Parameters

core/for_component_test.go:29-47
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)
	}
}
F
function

TestSafeJavaScriptCallbackReportsAndRuntimeContinues

Parameters

core/runtime_recovery_test.go:12-36
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)
	}
}
F
function

TestErrorSinkPanicDoesNotBlockOtherSinks

Parameters

core/runtime_recovery_test.go:38-50
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)
	}
}
F
function

TestScopeCancelsAndCleansUpOnce

Parameters

core/scope_test.go:10-31
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())
	}
}
F
function

TestScopeRunsLateCleanupImmediately

Parameters

core/scope_test.go:33-41
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")
	}
}
F
function

TestScopeRunsRemainingCleanupAfterPanic

Parameters

core/scope_test.go:43-52
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")
	}
}
S
struct

noopComponent

core/component_registry_test.go:7-7
type noopComponent struct

Methods

Render
Method

Returns

string
func (noopComponent) Render() string
{ return "" }
Mount
Method
func (noopComponent) Mount()
{}
Unmount
Method
func (noopComponent) Unmount()
{}
OnMount
Method
func (noopComponent) OnMount()
{}
OnUnmount
Method
func (noopComponent) OnUnmount()
{}
GetName
Method

Returns

string
func (noopComponent) GetName() string
{ return "noop" }
GetID
Method

Returns

string
func (noopComponent) GetID() string
{ return "noop" }
SetSlots
Method

Parameters

map[string]any
func (noopComponent) SetSlots(map[string]any)
{}
IsMounted
Method

Returns

bool
func (noopComponent) IsMounted() bool
{ return false }
OnParams
Method

Parameters

map[string]string
func (noopComponent) OnParams(map[string]string)
{}
F
function

TestRegisterComponentDuplicate

Parameters

core/component_registry_test.go:20-29
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")
	}
}
F
function

TestMustRegisterComponentPanic

Parameters

core/component_registry_test.go:31-40
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{} })
}
S
struct

panicComponent

core/error_boundary_test.go:7-7
type panicComponent struct

Methods

Render
Method

Returns

string
func (*panicComponent) Render() string
{ panic("boom") }
Mount
Method
func (*panicComponent) Mount()
{}
Unmount
Method
func (*panicComponent) Unmount()
{}
OnMount
Method
func (*panicComponent) OnMount()
{}
OnUnmount
Method
func (*panicComponent) OnUnmount()
{}
GetName
Method

Returns

string
func (*panicComponent) GetName() string
{ return "panic" }
GetID
Method

Returns

string
func (*panicComponent) GetID() string
{ return "panic" }
SetSlots
Method

Parameters

map[string]any
func (*panicComponent) SetSlots(map[string]any)
{}
IsMounted
Method

Returns

bool
func (*panicComponent) IsMounted() bool
{ return false }
OnParams
Method

Parameters

map[string]string
func (*panicComponent) OnParams(map[string]string)
{}
F
function

TestErrorBoundaryRender

Parameters

core/error_boundary_test.go:20-35
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")
	}
}
F
function

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

core/render_cache_dependency_test.go:15-42
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)
	}
}
I
interface

Node

Node renders a parsed template node.

core/rtml.go:41-43
type Node interface

Methods

Render
Method

Parameters

Returns

string
func Render(...)
S
struct

TextNode

TextNode contains literal template text.

core/rtml.go:46-48
type TextNode struct

Methods

Render
Method

Render returns the literal text.

Parameters

Returns

string
func (*TextNode) Render(*HTMLComponent) string
{ return t.Text }

Fields

Name Type Description
Text string
S
struct

ConditionalBranch

ConditionalBranch contains a conditional expression and its nodes.

core/rtml.go:54-57
type ConditionalBranch struct

Fields

Name Type Description
Condition string
Nodes []Node
S
struct
Implements: Node

ConditionalNode

ConditionalNode renders the first matching branch.

core/rtml.go:60-62
type ConditionalNode struct

Methods

Render
Method

Render evaluates the conditional branches and renders the appropriate content.

Parameters

Returns

string
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
S
struct

ConditionalBranchContent

ConditionalBranchContent stores rendered content for one branch.

core/rtml.go:65-68
type ConditionalBranchContent struct

Fields

Name Type Description
Condition string
Content string
S
struct

ConditionContent

ConditionContent stores all rendered branches of a conditional block.

core/rtml.go:71-73
type ConditionContent struct

Fields

Name Type Description
Branches []ConditionalBranchContent
S
struct

ConditionDependency

ConditionDependency identifies reactive state read by a condition.

core/rtml.go:76-81
type ConditionDependency struct

Fields

Name Type Description
module string
storeName string
key string
signal string
F
function

replaceIncludePlaceholders

Parameters

renderedTemplate
string

Returns

string
core/rtml.go:164-176
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
	})
}
F
function

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

template
string

Returns

string
core/rtml.go:182-224
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
}
F
function

extractSlotContents

Parameters

template
string

Returns

string
core/rtml.go:226-248
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
	})
}
F
function

replaceSlotPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:250-278
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
	})
}
F
function

escapeValue

escapeValue renders a substituted value HTML-escaped: template bindings are
text by default; @rawstore/@rawprop opt into trusted markup injection.

Parameters

v
any

Returns

string
core/rtml.go:282-284
func escapeValue(v any) string

{
	return html.EscapeString(fmt.Sprintf("%v", v))
}
F
function

replaceStorePlaceholders

Parameters

template
string

Returns

string
core/rtml.go:286-341
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
	})
}
F
function

replaceSignalPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:343-373
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
	})
}
F
function

replaceExprPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:375-403
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))
	})
}
F
function

replaceExprInClassAttr

Parameters

template
string

Returns

string
core/rtml.go:405-452
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
}
F
function

collectExprSignals

Parameters

Returns

map[string]any
core/rtml.go:454-458
func collectExprSignals(expr rtmlast.Expr, c *HTMLComponent) map[string]any

{
	refs := make(map[string]any)
	collectIdents(expr, c, refs)
	return refs
}
F
function

collectIdents

Parameters

refs
map[string]any
core/rtml.go:460-484
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)
	}
}
F
function

evalASTExprWithSigRefs

Parameters

sigRefs
map[string]any

Returns

any
core/rtml.go:486-575
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
}
F
function

cmpASTEqual

Parameters

a
any
b
any

Returns

bool
core/rtml.go:577-602
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)
}
F
function

cmpASTValues

Parameters

a
any
b
any

Returns

bool
core/rtml.go:604-618
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
	}
}
F
function

toASTFloat

Parameters

v
any

Returns

float64
core/rtml.go:620-639
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
	}
}
F
function

toASTFloatOk

Parameters

v
any

Returns

float64
bool
core/rtml.go:641-654
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
	}
}
F
function

toASTBool

Parameters

v
any

Returns

bool
core/rtml.go:656-669
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
	}
}
F
function

exprToString

Parameters

Returns

string
core/rtml.go:671-694
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 ""
}
F
function

binOpString

Parameters

Returns

string
core/rtml.go:696-725
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 "?"
	}
}
F
function

updateExprBindings

Parameters

exprID
string
newValue
any
core/rtml.go:727-739
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))
	}
}
F
function

updateClassExprBindings

Parameters

exprID
string
newValue
any
core/rtml.go:741-769
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)
		}
	}
}
F
function

replacePropPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:771-806
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
	})
}
F
function

replacePluginPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:808-834
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
}
F
function

replaceHostPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:836-863
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
}
F
function

replaceEventHandlers

Parameters

template
string

Returns

string
core/rtml.go:865-867
func replaceEventHandlers(template string) string

{
	return dom.ExpandEvents(template)
}
F
function

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

template
string

Returns

string
core/rtml.go:874-895
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)
	})
}
F
function

parseTemplate

parseTemplate parses the template string into an AST of nodes.

Parameters

template
string

Returns

error
core/rtml.go:898-902
func parseTemplate(template string) ([]Node, error)

{
	lines := strings.Split(template, "\n")
	idx := 0
	return parseBlock(lines, &idx)
}
F
function

parseBlock

Parameters

lines
[]string
idx
*int

Returns

error
core/rtml.go:904-926
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
}
F
function

parseConditional

Parameters

lines
[]string
idx
*int
firstCond
string

Returns

error
core/rtml.go:928-962
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
}
F
function

replaceConditionals

replaceConditionals parses conditionals using the AST and renders them.

Parameters

template
string

Returns

string
core/rtml.go:965-978
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()
}
F
function

evaluateCondition

Parameters

condition
string

Returns

core/rtml.go:980-1030
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
}
F
function

extractDependencies

Parameters

expr
string
core/rtml.go:1032-1046
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
}
F
function

updateStoreBindings

Parameters

module
string
storeName
string
key
string
newValue
any
core/rtml.go:1048-1100
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)
}
F
function

updateSignalBindings

Parameters

name
string
newValue
any
core/rtml.go:1102-1148
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))
		}
	}
}
F
function

insertDataKey

Parameters

content
string
key
any

Returns

string
core/rtml.go:1150-1157
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]:]
}
F
function

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

template
string

Returns

string
core/rtml.go:1167-1189
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)
	})
}
F
function

resolveNumber

Parameters

expr
string

Returns

int
error
core/rtml.go:1191-1229
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")
}
F
function

updateConditionsForStoreVariable

Parameters

module
string
storeName
string
key
string
core/rtml.go:1231-1246
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
				}
			}
		}
	}
}
F
function

getConditionDependencies

Parameters

condition
string

Returns

core/rtml.go:1248-1253
func getConditionDependencies(condition string) ([]ConditionDependency, error)

{
	expr := condition
	expr = strings.TrimPrefix(expr, "@if:")
	expr = strings.TrimPrefix(expr, "@else-if:")
	return extractDependencies(expr), nil
}
I
interface

Plugin

Plugin is a no-op stub for non-WASM builds.

core/plugin_stub.go:8-11
type Plugin interface

Methods

Build
Method

Parameters

Returns

error
func Build(...)
Install
Method

Parameters

*App
func Install(...)
I
interface

Named

Named exposes a plugin name.

core/plugin_stub.go:14-14
type Named interface

Methods

Name
Method

Returns

string
func Name(...)
I
interface

Requires

Requires lists mandatory plugin dependencies.

core/plugin_stub.go:17-17
type Requires interface

Methods

Requires
Method

Returns

[]Plugin
func Requires(...)
I
interface

Optional

Optional lists optional plugin dependencies.

core/plugin_stub.go:20-20
type Optional interface

Methods

Optional
Method

Returns

[]Plugin
func Optional(...)
I
interface

PreBuilder

PreBuilder runs before a build.

core/plugin_stub.go:23-23
type PreBuilder interface

Methods

PreBuild
Method

Parameters

Returns

error
func PreBuild(...)
I
interface

PostBuilder

PostBuilder runs after a build.

core/plugin_stub.go:26-26
type PostBuilder interface

Methods

PostBuild
Method

Parameters

Returns

error
func PostBuild(...)
I
interface

Uninstaller

Uninstaller removes plugin resources.

core/plugin_stub.go:29-29
type Uninstaller interface

Methods

Uninstall
Method

Parameters

*App
func Uninstall(...)
S
struct

App

App is a stub holder for callbacks.

core/plugin_stub.go:32-32
type App struct

Methods

RegisterRouter performs no work outside WASM.

Parameters

func(string)
func (*App) RegisterRouter(func(string))
{}
RegisterStore
Method

RegisterStore performs no work outside WASM.

Parameters

func(module, store, key string, value any)
func (*App) RegisterStore(func(module, store, key string, value any))
{}

RegisterLifecycle performs no work outside WASM.

Parameters

func(Component)
func(Component)
func (*App) RegisterLifecycle(func(Component), func(Component))
{}

RegisterTemplate performs no work outside WASM.

Parameters

func(componentID, html string)
func (*App) RegisterTemplate(func(componentID, html string))
{}

RegisterRTMLVar performs no work outside WASM.

Parameters

string
string
any
func (*App) RegisterRTMLVar(string, string, any)
{}
HasPlugin
Method

HasPlugin reports false outside WASM.

Parameters

string

Returns

bool
func (*App) HasPlugin(string) bool
{ return false }

RegisterRouter adds a router navigation hook.

Parameters

fn func(string)
func (*App) RegisterRouter(fn func(string))
{
	a.routerHooks = append(a.routerHooks, fn)
}
RegisterStore
Method

RegisterStore adds a store mutation hook.

Parameters

fn func(module, store, key string, value any)
func (*App) RegisterStore(fn func(module, store, key string, value any))
{
	a.storeHooks = append(a.storeHooks, fn)
}

RegisterTemplate adds a template render hook.

Parameters

fn func(componentID, html string)
func (*App) RegisterTemplate(fn func(componentID, html string))
{
	a.templateHooks = append(a.templateHooks, fn)
}

RegisterLifecycle adds hooks for component mount and unmount.

Parameters

mount func(Component)
unmount func(Component)
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

plugin string
name string
val any
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
Method

HasPlugin reports whether a plugin with the given name is installed.

Parameters

name string

Returns

bool
func (*App) HasPlugin(name string) bool
{
	if a.plugins == nil {
		return false
	}
	_, ok := a.plugins[name]
	return ok
}
F
function

RegisterPlugin

RegisterPlugin performs no work outside WASM.

Parameters

core/plugin_stub.go:53-53
func RegisterPlugin(Plugin)

{}
F
function

TriggerRouter

TriggerRouter performs no work outside WASM.

Parameters

string
core/plugin_stub.go:56-56
func TriggerRouter(string)

{}
F
function

TriggerStore

TriggerStore performs no work outside WASM.

Parameters

string
string
string
any
core/plugin_stub.go:59-59
func TriggerStore(string, string, string, any)

{}
F
function

TriggerMount

TriggerMount performs no work outside WASM.

Parameters

core/plugin_stub.go:62-62
func TriggerMount(Component)

{}
F
function

TriggerUnmount

TriggerUnmount performs no work outside WASM.

Parameters

core/plugin_stub.go:65-65
func TriggerUnmount(Component)

{}
F
function

TriggerTemplate

TriggerTemplate performs no work outside WASM.

Parameters

string
string
core/plugin_stub.go:68-68
func TriggerTemplate(string, string)

{}
F
function

OnNavigate

OnNavigate performs no work outside WASM.

Parameters

func(string)
core/plugin_stub.go:71-71
func OnNavigate(func(string))

{}
F
function

OnTemplate

OnTemplate performs no work outside WASM.

Parameters

func(componentID,
html string)
core/plugin_stub.go:74-74
func OnTemplate(func(componentID, html string))

{}
F
function

RegisterPluginVar

RegisterPluginVar performs no work outside WASM.

Parameters

string
string
any
core/plugin_stub.go:77-77
func RegisterPluginVar(string, string, any)

{}
F
function

TestProvideInject

Parameters

core/provide_inject_test.go:11-27
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)
	}
}
F
function

devOverrideTemplate

Parameters

template
string

Returns

string
core/dev_noop_wasm.go:5-5
func devOverrideTemplate(_ *HTMLComponent, template string) string

{ return template }
F
function

devRegisterComponent

Parameters

core/dev_noop_wasm.go:6-6
func devRegisterComponent(*HTMLComponent)

{}
F
function

devUnregisterComponent

Parameters

core/dev_noop_wasm.go:7-7
func devUnregisterComponent(*HTMLComponent)

{}
F
function

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

string
core/error_recovery.go:15-22
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()
}
F
function

TryMount

TryMount wraps a component’s Mount() with panic recovery.

Parameters

core/error_recovery.go:25-32
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()
}
F
function

TryUnmount

TryUnmount wraps a component’s Unmount() with panic recovery.

Parameters

core/error_recovery.go:35-42
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()
}
F
function

TryNavigate

TryNavigate wraps router navigation with panic recovery.

Parameters

path
string
fn
func()
core/error_recovery.go:45-52
func TryNavigate(path string, fn func())

{
	defer func() {
		if r := recover(); r != nil {
			ReportError(r, fmt.Sprintf("Navigate: %s", path))
		}
	}()
	fn()
}
F
function

TryEffect

TryEffect wraps an effect function with panic recovery.

Parameters

fn
func() func()

Returns

func()
core/error_recovery.go:55-65
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()
	})
}
F
function

TryTemplateLoad

TryTemplateLoad wraps template loading with recovery.

Parameters

fn
func()
core/error_recovery.go:68-75
func TryTemplateLoad(fn func())

{
	defer func() {
		if r := recover(); r != nil {
			ReportError(r, "Template / Composition")
		}
	}()
	fn()
}
S
struct

unsubscribes

core/html_component.go:29-31
type unsubscribes struct

Methods

Add
Method

Parameters

fn func()
func (*unsubscribes) Add(fn func())
{ u.funcs = append(u.funcs, fn) }
Run
Method
func (*unsubscribes) Run()
{
	for _, fn := range u.funcs {
		fn()
	}
	u.funcs = nil
}

Fields

Name Type Description
funcs []func()
S
struct

HTMLComponent

HTMLComponent renders RTML templates and manages their component state.

core/html_component.go:43-85
type HTMLComponent struct

Methods

Stats
Method

Stats returns zeroed metrics on non-wasm builds.

Returns

func (*HTMLComponent) Stats() ComponentStats
{ return ComponentStats{} }
requestRender
Method

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
Method

Render returns no markup outside WASM.

Returns

string
func (*HTMLComponent) Render() string
{ return "" }
Mount
Method

Mount performs no work outside WASM.

func (*HTMLComponent) Mount()
{}
Unmount
Method

Unmount performs no work outside WASM.

func (*HTMLComponent) Unmount()
{}
OnMount
Method

OnMount performs no work outside WASM.

func (*HTMLComponent) OnMount()
{}
OnUnmount
Method

OnUnmount performs no work outside WASM.

func (*HTMLComponent) OnUnmount()
{}
GetName
Method

GetName returns the component name.

Returns

string
func (*HTMLComponent) GetName() string
{ return c.Name }
GetID
Method

GetID returns the component ID.

Returns

string
func (*HTMLComponent) GetID() string
{ return c.ID }
SetSlots
Method

SetSlots performs no work outside WASM.

Parameters

map[string]any
func (*HTMLComponent) SetSlots(map[string]any)
{}
Scope
Method

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
Method

Init attaches a state store and prepares the component template.

Parameters

store *state.Store
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
Method

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

string
func (*HTMLComponent) RenderFresh() string
{
	c.Invalidate()
	return c.Render()
}
Invalidate
Method

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
Method

Render evaluates the component template.

Returns

renderedTemplate string
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
}
recordRender
Method

Parameters

duration time.Duration
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
Method

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
Method

AddDependency attaches a child component to a template placeholder.

Parameters

placeholderName string
dep Component
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
Method

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
Method

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)
}
runLifecycle
Method

Parameters

phase string
fn func()
func (*HTMLComponent) runLifecycle(phase string, fn func())
{
	defer func() {
		if recovered := recover(); recovered != nil {
			ReportError(recovered, phase+": "+c.Name+" (ID: "+c.ID+")")
		}
	}()
	fn()
}
Scope
Method

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
Method

Effect registers a reactive effect that stops on unmount.

Parameters

fn func() func()
func (*HTMLComponent) Effect(fn func() func())
{
	c.Scope().Defer(state.Effect(fn))
}
DOMHook
Method

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
Method

On registers an event handler owned by this component instance.

Parameters

name string
fn func()
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
Method

GetName returns the component name.

Returns

string
func (*HTMLComponent) GetName() string
{
	return c.Name
}
GetID
Method

GetID returns the component identifier.

Returns

string
func (*HTMLComponent) GetID() string
{
	return c.ID
}
GetRef
Method

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

name string

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
Method

OnMount runs the configured mount callback.

func (*HTMLComponent) OnMount()
{
	if c.onMount != nil {
		c.onMount(c)
	}
}
OnUnmount
Method

OnUnmount runs the configured unmount callback.

func (*HTMLComponent) OnUnmount()
{
	if c.onUnmount != nil {
		c.onUnmount(c)
	}
	c.mounted = false
}
IsMounted
Method

IsMounted reports whether the component is mounted.

Returns

bool
func (*HTMLComponent) IsMounted() bool
{
	return c.mounted
}
OnParams
Method

OnParams runs the configured route-parameter callback.

Parameters

params map[string]string
func (*HTMLComponent) OnParams(params map[string]string)
{
	if c.onParams != nil {
		c.onParams(c, params)
	}
}
SetOnParams
Method

SetOnParams configures the route-parameter callback.

Parameters

fn func(*HTMLComponent, map[string]string)
func (*HTMLComponent) SetOnParams(fn func(*HTMLComponent, map[string]string))
{
	c.onParams = fn
}
SetOnMount
Method

SetOnMount configures the mount callback.

Parameters

fn func(*HTMLComponent)
func (*HTMLComponent) SetOnMount(fn func(*HTMLComponent))
{
	c.onMount = fn
}
SetOnUnmount
Method

SetOnUnmount configures the unmount callback.

Parameters

fn func(*HTMLComponent)
func (*HTMLComponent) SetOnUnmount(fn func(*HTMLComponent))
{
	c.onUnmount = fn
}
WithLifecycle
Method

WithLifecycle configures mount and unmount callbacks.

Parameters

onMount func(*HTMLComponent)
onUnmount func(*HTMLComponent)

Returns

func (*HTMLComponent) WithLifecycle(onMount, onUnmount func(*HTMLComponent)) *HTMLComponent
{
	c.onMount = onMount
	c.onUnmount = onUnmount
	return c
}
SetComponent
Method

SetComponent attaches the component lifecycle implementation.

Parameters

component Component
func (*HTMLComponent) SetComponent(component Component)
{
	c.component = component
}
SetSlots
Method

SetSlots merges named slot content into the component.

Parameters

slots map[string]any
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
Method

Provide stores a value on this component so that descendants can retrieve it with Inject. It creates the map on first use.

Parameters

key string
val any
func (*HTMLComponent) Provide(key string, val any)
{
	if c.provides == nil {
		c.provides = make(map[string]any)
	}
	c.provides[key] = val
}
Inject
Method

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

key string

Returns

any
bool
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

params map[string]string
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

name string
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

[]string
func (*HTMLComponent) hostComponentNames() []string
{
	if len(c.hostComponents) > 0 {
		return c.hostComponents
	}
	if c.HostComponent != "" {
		return []string{c.HostComponent}
	}
	return nil
}
cacheKey
Method

Returns

string
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
S
struct

ComponentStats

ComponentStats contains aggregated render metrics for an HTML component.

core/html_component.go:88-94
type ComponentStats struct

Fields

Name Type Description
RenderCount int
TotalRender time.Duration
LastRender time.Duration
AverageRender time.Duration
Timeline []ComponentTimelineEntry
S
struct

ComponentTimelineEntry

ComponentTimelineEntry represents a point-in-time event collected for diagnostics.

core/html_component.go:97-101
type ComponentTimelineEntry struct

Fields

Name Type Description
Kind string
Timestamp time.Time
Duration time.Duration
F
function

NewHTMLComponent

NewHTMLComponent creates a component from an RTML template and initial props.

Parameters

name
string
templateFs
[]byte
props
map[string]any

Returns

core/html_component.go:104-122
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
}
F
function

minifyInline

Parameters

src
string

Returns

string
core/html_component.go:321-340
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)
	})
}
F
function

Inject

Inject performs a typed lookup of a provided component value.

Parameters

key
string

Returns

T
bool
core/html_component.go:583-591
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
}
F
function

generateComponentID

Parameters

name
string
props
map[string]any

Returns

string
core/html_component.go:650-658
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])
}
F
function

serializeProps

Parameters

props
map[string]any

Returns

string
core/html_component.go:660-677
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()
}
F
function

TestRenderSchedulerCoalescesStoreBurst

Parameters

core/render_scheduler_test.go:15-39
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)
	}
}
F
function

TestRenderSchedulerCancelsUnmountedComponent

Parameters

core/render_scheduler_test.go:41-59
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)
	}
}
F
function

TestRenderSchedulerContinuesAfterDOMFailure

Parameters

core/render_scheduler_test.go:61-84
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)
	}
}
F
function

TestRenderSchedulerCommitsParentBeforeChild

Parameters

core/render_scheduler_test.go:86-119
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)
	}
}
F
function

TestRenderSchedulerCommitsSiblingsByStableID

Parameters

core/render_scheduler_test.go:121-141
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)
	}
}
F
function

waitForRenderFlush

core/render_scheduler_test.go:143-145
func waitForRenderFlush()

{
	time.Sleep(20 * time.Millisecond)
}
F
function

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

core/rtml_condition_refresh_test.go:15-56
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)
	}
}
F
function

LoadComponentTemplate

LoadComponentTemplate validates and returns embedded template data.

Parameters

templateFs
[]byte

Returns

string
error
core/template_loader.go:8-15
func LoadComponentTemplate(templateFs []byte) (string, error)

{
	template := string(templateFs)
	if template == "" {
		return "", fmt.Errorf("template is empty")
	}

	return template, nil
}
I
interface

Component

Component defines the minimal interface exposed to plugins in non-WASM builds.

core/component_host.go:12-16
type Component interface

Methods

Render
Method

Returns

string
func Render(...)
GetName
Method

Returns

string
func GetName(...)
GetID
Method

Returns

string
func GetID(...)
F
function

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

name
string
constructor
func() Component

Returns

error
core/component_host.go:27-36
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
}
F
function

LoadComponent

LoadComponent retrieves a component constructor by name. If no component is
registered under that name, nil is returned.

Parameters

name
string

Returns

core/component_host.go:40-48
func LoadComponent(name string) Component

{
	componentRegistryMu.RLock()
	ctor, ok := ComponentRegistry[name]
	componentRegistryMu.RUnlock()
	if ok {
		return ctor()
	}
	return nil
}
F
function

TestComponentRegistryConcurrentAccess

Parameters

core/component_registry_concurrent_test.go:9-30
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()
}
S
struct

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.

core/error_boundary.go:10-15
type ErrorBoundary struct

Methods

fallbackHTML
Method

Returns

string
func (*ErrorBoundary) fallbackHTML() string
{
	return "<root data-component-id=\"" + e.Child.GetID() + "\">" + e.Fallback + "</root>"
}
Render
Method

Render renders the child component, returning the fallback HTML if the child panics or if a previous panic was recorded.

Returns

out string
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
Method

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
Method

Unmount delegates to the child component's Unmount method.

func (*ErrorBoundary) Unmount()
{
	e.Child.Unmount()
	e.mounted = false
}
OnMount
Method

OnMount is a no-op for ErrorBoundary.

func (*ErrorBoundary) OnMount()
{}
OnUnmount
Method

OnUnmount is a no-op for ErrorBoundary.

func (*ErrorBoundary) OnUnmount()
{}
GetName
Method

GetName returns the name of the component.

Returns

string
func (*ErrorBoundary) GetName() string
{ return "ErrorBoundary" }
GetID
Method

GetID returns the wrapped child's ID.

Returns

string
func (*ErrorBoundary) GetID() string
{ return e.Child.GetID() }
SetSlots
Method

SetSlots delegates slot assignment to the child component.

Parameters

slots map[string]any
func (*ErrorBoundary) SetSlots(slots map[string]any)
{
	if e.Child != nil {
		e.Child.SetSlots(slots)
	}
}
IsMounted
Method

IsMounted reports whether the boundary or its fallback is mounted.

Returns

bool
func (*ErrorBoundary) IsMounted() bool
{ return e.mounted }
OnParams
Method

OnParams delegates route parameters to the wrapped component.

Parameters

params map[string]string
func (*ErrorBoundary) OnParams(params map[string]string)
{
	e.Child.OnParams(params)
}

Fields

Name Type Description
Child Component
Fallback string
err any
mounted bool
F
function

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

child
fallback
string

Returns

core/error_boundary.go:22-24
func NewErrorBoundary(child Component, fallback string) *ErrorBoundary

{
	return &ErrorBoundary{Child: child, Fallback: fallback}
}
I
interface

Logger

Logger defines logging interface used by the framework.

core/logger.go:10-15
type Logger interface

Methods

Debug
Method

Parameters

format string
v ...any
func Debug(...)
Info
Method

Parameters

format string
v ...any
func Info(...)
Warn
Method

Parameters

format string
v ...any
func Warn(...)
Error
Method

Parameters

format string
v ...any
func Error(...)
F
function

init

core/logger.go:20-20
func init()

{ state.SetLogger(logger) }
F
function

SetLogger

SetLogger allows applications to replace the default logger.

Parameters

l
core/logger.go:23-28
func SetLogger(l Logger)

{
	if l != nil {
		logger = l
		state.SetLogger(l)
	}
}
F
function

Log

Log returns the active logger implementation.

Returns

core/logger.go:31-31
func Log() Logger

{ return logger }
S
struct
Implements: Logger

defaultLogger

defaultLogger is the fallback logger using the standard log package.

core/logger.go:34-34
type defaultLogger struct

Methods

Debug
Method

Parameters

format string
v ...any
func (defaultLogger) Debug(format string, v ...any)
{ log.Printf("DEBUG: "+format, v...) }
Info
Method

Parameters

format string
v ...any
func (defaultLogger) Info(format string, v ...any)
{ log.Printf("INFO: "+format, v...) }
Warn
Method

Parameters

format string
v ...any
func (defaultLogger) Warn(format string, v ...any)
{ log.Printf("WARN: "+format, v...) }
Error
Method

Parameters

format string
v ...any
func (defaultLogger) Error(format string, v ...any)
{ log.Printf("ERROR: "+format, v...) }
I
interface

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.

core/plugin.go:15-18
type Plugin interface

Methods

Build
Method

Parameters

Returns

error
func Build(...)
Install
Method

Parameters

*App
func Install(...)
I
interface

Named

Named plugins expose a unique identifier used for deduplication.
Implementing this interface is optional.

core/plugin.go:22-22
type Named interface

Methods

Name
Method

Returns

string
func Name(...)
I
interface

Requires

Requires allows plugins to declare mandatory dependencies.
Implementing this interface is optional.

core/plugin.go:26-26
type Requires interface

Methods

Requires
Method

Returns

[]Plugin
func Requires(...)
I
interface

Optional

Optional allows plugins to declare optional dependencies.
Implementing this interface is optional.

core/plugin.go:30-30
type Optional interface

Methods

Optional
Method

Returns

[]Plugin
func Optional(...)
I
interface

PreBuilder

PreBuilder allows plugins to execute logic before the CLI build step.
Implementing this interface is optional.

core/plugin.go:34-36
type PreBuilder interface

Methods

PreBuild
Method

Parameters

Returns

error
func PreBuild(...)
I
interface

PostBuilder

PostBuilder allows plugins to execute logic after the CLI build step.
Implementing this interface is optional.

core/plugin.go:40-42
type PostBuilder interface

Methods

PostBuild
Method

Parameters

Returns

error
func PostBuild(...)
I
interface

Uninstaller

Uninstaller allows plugins to clean up previously registered hooks.
Implementing this interface is optional.

core/plugin.go:46-48
type Uninstaller interface

Methods

Uninstall
Method

Parameters

*App
func Uninstall(...)
I
interface

Provider

Provider allows plugins to expose typed data to components via the DI container.
Implementing this interface is optional.

core/plugin.go:52-54
type Provider interface

Methods

Provide
Method

Returns

map[string]any
func Provide(...)
S
struct

App

App maintains registered hooks and exposes helper methods for plugins
to attach to framework events.

core/plugin.go:58-63
type App struct

Methods

RegisterRouter performs no work outside WASM.

Parameters

func(string)
func (*App) RegisterRouter(func(string))
{}
RegisterStore
Method

RegisterStore performs no work outside WASM.

Parameters

func(module, store, key string, value any)
func (*App) RegisterStore(func(module, store, key string, value any))
{}

RegisterLifecycle performs no work outside WASM.

Parameters

func(Component)
func(Component)
func (*App) RegisterLifecycle(func(Component), func(Component))
{}

RegisterTemplate performs no work outside WASM.

Parameters

func(componentID, html string)
func (*App) RegisterTemplate(func(componentID, html string))
{}

RegisterRTMLVar performs no work outside WASM.

Parameters

string
string
any
func (*App) RegisterRTMLVar(string, string, any)
{}
HasPlugin
Method

HasPlugin reports false outside WASM.

Parameters

string

Returns

bool
func (*App) HasPlugin(string) bool
{ return false }

RegisterRouter adds a router navigation hook.

Parameters

fn func(string)
func (*App) RegisterRouter(fn func(string))
{
	a.routerHooks = append(a.routerHooks, fn)
}
RegisterStore
Method

RegisterStore adds a store mutation hook.

Parameters

fn func(module, store, key string, value any)
func (*App) RegisterStore(fn func(module, store, key string, value any))
{
	a.storeHooks = append(a.storeHooks, fn)
}

RegisterTemplate adds a template render hook.

Parameters

fn func(componentID, html string)
func (*App) RegisterTemplate(fn func(componentID, html string))
{
	a.templateHooks = append(a.templateHooks, fn)
}

RegisterLifecycle adds hooks for component mount and unmount.

Parameters

mount func(Component)
unmount func(Component)
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

plugin string
name string
val any
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
Method

HasPlugin reports whether a plugin with the given name is installed.

Parameters

name string

Returns

bool
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
S
struct

hooks

core/plugin.go:65-71
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)
F
function

newApp

newApp creates an App with initialized hook storage.

Returns

core/plugin.go:74-76
func newApp() *App

{
	return &App{hooks: &hooks{}, pluginVars: make(map[string]map[string]any), plugins: make(map[string]Plugin), provides: make(map[string]any)}
}
F
function

getRTMLVar

getRTMLVar retrieves a registered plugin variable.

Parameters

plugin
string
name
string

Returns

any
bool
core/plugin.go:116-125
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
}
F
function

RegisterPluginVar

RegisterPluginVar is a convenience wrapper for plugins to expose variables.

Parameters

plugin
string
name
string
val
any
core/plugin.go:128-130
func RegisterPluginVar(plugin, name string, val any)

{
	app.RegisterRTMLVar(plugin, name, val)
}
F
function

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

p
core/plugin.go:145-181
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
		}
	}
}
F
function

GetProvider

GetProvider retrieves a value provided by a plugin via its Provider interface.

Parameters

key
string

Returns

any
bool
core/plugin.go:184-187
func GetProvider(key string) (any, bool)

{
	v, ok := app.provides[key]
	return v, ok
}
F
function

TriggerRouter

TriggerRouter invokes router hooks with the given path.

Parameters

path
string
core/plugin.go:190-194
func TriggerRouter(path string)

{
	for _, h := range app.routerHooks {
		h(path)
	}
}
F
function

OnNavigate

OnNavigate registers a function that is called whenever the router navigates
to a new path. The callback receives the full path (including query string).

Parameters

fn
func(string)
core/plugin.go:198-200
func OnNavigate(fn func(string))

{
	app.routerHooks = append(app.routerHooks, fn)
}
F
function

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

fn
func(componentID, html string)
core/plugin.go:205-207
func OnTemplate(fn func(componentID, html string))

{
	app.templateHooks = append(app.templateHooks, fn)
}
F
function

TriggerStore

TriggerStore invokes store hooks for a mutation.

Parameters

module
string
store
string
key
string
value
any
core/plugin.go:210-214
func TriggerStore(module, store, key string, value any)

{
	for _, h := range app.storeHooks {
		h(module, store, key, value)
	}
}
F
function

TriggerTemplate

TriggerTemplate invokes template hooks with rendered HTML for a component.

Parameters

componentID
string
html
string
core/plugin.go:217-221
func TriggerTemplate(componentID, html string)

{
	for _, h := range app.templateHooks {
		h(componentID, html)
	}
}
F
function

TriggerMount

TriggerMount invokes mount lifecycle hooks.

Parameters

core/plugin.go:224-228
func TriggerMount(c Component)

{
	for _, h := range app.mountHooks {
		h(c)
	}
}
F
function

TriggerUnmount

TriggerUnmount invokes unmount lifecycle hooks.

Parameters

core/plugin.go:231-235
func TriggerUnmount(c Component)

{
	for _, h := range app.unmountHooks {
		h(c)
	}
}
F
function

init

core/plugin.go:237-240
func init()

{
	state.StoreHook = TriggerStore
	dom.TemplateHook = TriggerTemplate
}
F
function

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

core/rtml_condition_duplicate_test.go:15-56
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)
	}
}
F
function

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

core/rtml_condition_duplicate_test.go:60-96
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])
		}
	}
}
F
function

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

core/rtml_condition_store_test.go:15-55
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)
	}
}
F
function

TestNamedSlotExtraction

Parameters

core/slot_test.go:12-30
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)
	}
}
F
function

TestIncludePlaceholderPrefixCollision

Parameters

core/slot_test.go:32-51
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)
	}
}
F
function

TestComponentInstancesHaveDistinctIDs

Parameters

core/slot_test.go:53-60
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)
	}
}
F
function

SetDevMode

SetDevMode toggles development mode features.

Parameters

enabled
bool
core/component.go:15-17
func SetDevMode(enabled bool)

{
	DevMode = enabled
}
I
interface

Component

Component defines the lifecycle and rendering contract for UI components.

core/component.go:20-33
type Component interface

Methods

Render
Method

Returns

string
func Render(...)
Mount
Method
func Mount(...)
Unmount
Method
func Unmount(...)
OnMount
Method
func OnMount(...)
OnUnmount
Method
func OnUnmount(...)
GetName
Method

Returns

string
func GetName(...)
GetID
Method

Returns

string
func GetID(...)
SetSlots
Method

Parameters

map[string]any
func SetSlots(...)
IsMounted
Method

Returns

bool
func IsMounted(...)
OnParams
Method

Parameters

params map[string]string
func OnParams(...)
F
function

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

name
string
constructor
func() Component

Returns

error
core/component.go:49-58
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
}
F
function

LoadComponent

LoadComponent retrieves a component by name using the registry. If no
component is registered under that name, nil is returned.

Parameters

name
string

Returns

core/component.go:62-70
func LoadComponent(name string) Component

{
	componentRegistryMu.RLock()
	ctor, ok := ComponentRegistry[name]
	componentRegistryMu.RUnlock()
	if ok {
		return ctor()
	}
	return nil
}
F
function

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

name
string
templateFS
[]byte
props
map[string]any

Returns

core/component.go:75-80
func NewComponent(name string, templateFS []byte, props map[string]any) *HTMLComponent

{
	c := NewHTMLComponent(name, templateFS, props)
	c.SetComponent(c)
	c.Init(nil)
	return c
}
F
function

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

name
string
templateFS
[]byte
props
map[string]any
self
T

Returns

core/component.go:85-94
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
}
F
function

reportScopeError

Parameters

err
any
core/scope_error_host.go:7-9
func reportScopeError(err any)

{
	log.Printf("component scope cleanup: %v", err)
}
S
struct

Suspense

Suspense renders a fallback while its render function reports pending work.

core/suspense_component.go:15-23
type Suspense struct

Methods

Render
Method

Render executes the render function and shows the fallback until it resolves.

Returns

string
func (*Suspense) Render() string
{
	s.last = s.renderHTML()
	return s.last
}
renderHTML
Method

Returns

string
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
Method

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
Method

Unmount releases the reactive render subscription.

func (*Suspense) Unmount()
{
	s.mounted = false
	cancelComponentRender(s.id)
	if s.stop != nil {
		s.stop()
		s.stop = nil
	}
}
OnMount
Method

OnMount is a no-op for Suspense.

func (*Suspense) OnMount()
{}
OnUnmount
Method

OnUnmount is a no-op for Suspense.

func (*Suspense) OnUnmount()
{}
GetName
Method

GetName returns the component name.

Returns

string
func (*Suspense) GetName() string
{ return "Suspense" }
GetID
Method

GetID returns this Suspense instance ID.

Returns

string
func (*Suspense) GetID() string
{ return s.id }
SetSlots
Method

SetSlots is a no-op since Suspense does not use slots.

Parameters

map[string]any
func (*Suspense) SetSlots(map[string]any)
{}
IsMounted
Method

IsMounted reports whether Suspense is mounted.

Returns

bool
func (*Suspense) IsMounted() bool
{ return s.mounted }
OnParams
Method

OnParams is a no-op since Suspense does not consume route parameters.

Parameters

map[string]string
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()
F
function

NewSuspense

NewSuspense creates a Suspense component with the given render function and fallback HTML.

Parameters

render
func() (string, error)
fallback
string

Returns

core/suspense_component.go:28-34
func NewSuspense(render func() (string, error), fallback string) *Suspense

{
	return &Suspense{
		render:   render,
		fallback: fallback,
		id:       generateComponentID("Suspense", nil),
	}
}