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