core API

core

package

API reference for the core package.

S
struct

Scope

Scope owns work that must stop with a component.

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

Methods

Context
Method

Context is cancelled when the scope closes.

Returns

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

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

Parameters

fn func()
func (*Scope) Defer(fn func())
{
	if fn == nil {
		return
	}
	s.mu.Lock()
	if s.closed {
		s.mu.Unlock()
		fn()
		return
	}
	s.cleanups = append(s.cleanups, fn)
	s.mu.Unlock()
}
Go
Method

Go starts work with the scope context.

Parameters

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

Close cancels the context and runs registered cleanup once.

func (*Scope) Close()
{
	s.mu.Lock()
	if s.closed {
		s.mu.Unlock()
		return
	}
	s.closed = true
	cancel := s.cancel
	cleanups := s.cleanups
	s.cleanups = nil
	s.mu.Unlock()

	cancel()
	for i := len(cleanups) - 1; i >= 0; i-- {
		func(cleanup func()) {
			defer func() {
				if recovered := recover(); recovered != nil {
					reportScopeError(recovered)
				}
			}()
			cleanup()
		}(cleanups[i])
	}
}
Closed
Method

Closed reports whether Close has run.

Returns

bool
func (*Scope) Closed() bool
{
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.closed
}

Fields

Name Type Description
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
cleanups []func()
closed bool
F
function

NewScope

NewScope creates an open lifecycle scope.

Returns

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

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

TestScopeCancelsAndCleansUpOnce

Parameters

core/scope_test.go:10-31
func TestScopeCancelsAndCleansUpOnce(t *testing.T)

{
	scope := NewScope()
	var cleanups atomic.Int32
	cancelled := make(chan struct{})
	scope.Defer(func() { cleanups.Add(1) })
	scope.Go(func(ctx context.Context) {
		<-ctx.Done()
		close(cancelled)
	})

	scope.Close()
	scope.Close()

	select {
	case <-cancelled:
	case <-time.After(time.Second):
		t.Fatal("scope context was not cancelled")
	}
	if cleanups.Load() != 1 {
		t.Fatalf("cleanup count = %d", cleanups.Load())
	}
}
F
function

TestScopeRunsLateCleanupImmediately

Parameters

core/scope_test.go:33-41
func TestScopeRunsLateCleanupImmediately(t *testing.T)

{
	scope := NewScope()
	scope.Close()
	called := false
	scope.Defer(func() { called = true })
	if !called {
		t.Fatal("late cleanup did not run")
	}
}
F
function

TestScopeRunsRemainingCleanupAfterPanic

Parameters

core/scope_test.go:43-52
func TestScopeRunsRemainingCleanupAfterPanic(t *testing.T)

{
	scope := NewScope()
	ran := false
	scope.Defer(func() { ran = true })
	scope.Defer(func() { panic("cleanup") })
	scope.Close()
	if !ran {
		t.Fatal("cleanup after panic did not run")
	}
}
S
struct
Implements: Component

Suspense

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

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

Methods

Render
Method

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

Returns

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

Returns

string
func (*Suspense) renderHTML() string
{
	content := s.fallback
	if s.render == nil {
		return `<root data-component-id="` + s.id + `">` + content + `</root>`
	}
	rendered, err := s.render()
	switch {
	case errors.Is(err, http.ErrPending), errors.Is(err, state.ErrResourcePending):
	case err != nil:
		content = html.EscapeString(err.Error())
	default:
		content = rendered
	}
	return `<root data-component-id="` + s.id + `">` + content + `</root>`
}
Mount
Method

Mount subscribes to every reactive value read by the render function.

func (*Suspense) Mount()
{
	s.mounted = true
	if s.stop != nil {
		s.stop()
	}
	s.stop = state.Effect(func() func() {
		next := s.renderHTML()
		if s.mounted && next != s.last {
			s.last = next
			dom.UpdateMountedDOM(s.id, next)
		}
		return nil
	})
}
Unmount
Method

Unmount releases the reactive render subscription.

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

OnMount is a no-op for Suspense.

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

OnUnmount is a no-op for Suspense.

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

GetName returns the component name.

Returns

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

GetID returns this Suspense instance ID.

Returns

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

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

Parameters

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

IsMounted reports whether Suspense is mounted.

Returns

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

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

Parameters

map[string]string
func (*Suspense) OnParams(map[string]string)
{}

Fields

Name Type Description
render func() (string, error)
fallback string
id string
mounted bool
last string
stop func()
F
function

NewSuspense

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

Parameters

render
func() (string, error)
fallback
string

Returns

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

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

LoadComponentTemplate

LoadComponentTemplate validates and returns embedded template data.

Parameters

templateFs
[]byte

Returns

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

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

	return template, nil
}
F
function

TestSuspenseMountState

Parameters

core/suspense_component_test.go:15-28
func TestSuspenseMountState(t *testing.T)

{
	s := NewSuspense(func() (string, error) { return "ready", nil }, "loading")
	if s.IsMounted() {
		t.Fatal("new Suspense should not be mounted")
	}
	s.Mount()
	if !s.IsMounted() {
		t.Fatal("Suspense should be mounted after Mount")
	}
	s.Unmount()
	if s.IsMounted() {
		t.Fatal("Suspense should not be mounted after Unmount")
	}
}
F
function

TestSuspenseUpdatesWhenResourceResolves

Parameters

core/suspense_component_test.go:30-67
func TestSuspenseUpdatesWhenResourceResolves(t *testing.T)

{
	if dom.ByID("app").IsNull() {
		host := dom.CreateElement("div")
		host.SetAttr("id", "app")
		dom.Doc().Body().AppendChild(host)
	}

	release := make(chan struct{})
	resource := state.NewResource(func(context.Context) (string, error) {
		<-release
		return "ready", nil
	})
	defer resource.Close()

	suspense := NewSuspense(func() (string, error) {
		value, err := resource.Read()
		return "<p>" + value + "</p>", err
	}, "<p>loading</p>")
	dom.UpdateDOM(suspense.GetID(), suspense.Render())
	suspense.Mount()
	defer suspense.Unmount()

	if html := dom.ComponentRoot(suspense.GetID()).HTML(); !strings.Contains(html, "loading") {
		t.Fatalf("fallback missing: %s", html)
	}
	close(release)

	deadline := time.Now().Add(time.Second)
	for {
		if html := dom.ComponentRoot(suspense.GetID()).HTML(); strings.Contains(html, "ready") {
			break
		}
		if time.Now().After(deadline) {
			t.Fatalf("resolved content missing: %s", dom.ComponentRoot(suspense.GetID()).HTML())
		}
		time.Sleep(time.Millisecond)
	}
}
F
function

SetDevMode

SetDevMode toggles development mode features.

Parameters

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

{
	DevMode = enabled
}
I
interface

Component

Component defines the lifecycle and rendering contract for UI components.

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

Methods

Render
Method

Returns

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

Returns

string
func GetName(...)
GetID
Method

Returns

string
func GetID(...)
SetSlots
Method

Parameters

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

Returns

bool
func IsMounted(...)
OnParams
Method

Parameters

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

RegisterComponent

RegisterComponent registers a component constructor under the provided name.
When a template references the name via rt-is, the constructor will be
invoked to create a new component instance at render time. It returns an
error if a component with the same name is already registered and logs a
warning.

Parameters

name
string
constructor
func() Component

Returns

error
core/component.go:49-58
func RegisterComponent(name string, constructor func() Component) error

{
	componentRegistryMu.Lock()
	defer componentRegistryMu.Unlock()
	if _, exists := ComponentRegistry[name]; exists {
		Log().Warn("component %s already registered", name)
		return fmt.Errorf("component %s already registered", name)
	}
	ComponentRegistry[name] = constructor
	return nil
}
F
function

LoadComponent

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

Parameters

name
string

Returns

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

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

NewComponent

NewComponent creates an HTMLComponent initialized with the provided
template and props. It sets itself as the underlying component and
performs initialization with the default store.

Parameters

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

Returns

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

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

NewComponentWith

NewComponentWith creates an HTMLComponent and binds it to the given
component implementation. This is useful when embedding HTMLComponent
inside another struct to override lifecycle hooks.

Parameters

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

Returns

core/component.go:85-94
func NewComponentWith[T Component](name string, templateFS []byte, props map[string]any, self T) *HTMLComponent

{
	c := NewHTMLComponent(name, templateFS, props)
	if any(self) != nil {
		c.SetComponent(self)
	} else {
		c.SetComponent(c)
	}
	c.Init(nil)
	return c
}
S
struct

ErrorBoundary

ErrorBoundary wraps a child component and renders a fallback UI when the
child panics during Render or Mount. Once a panic occurs, the fallback UI is
displayed for subsequent renders.

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

Methods

fallbackHTML
Method

Returns

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

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

Returns

out string
func (*ErrorBoundary) Render() (out string)
{
	if e.err != nil {
		return e.fallbackHTML()
	}
	defer func() {
		if r := recover(); r != nil {
			e.err = r
			ReportError(r, "Boundary render: "+e.Child.GetName())
			out = e.fallbackHTML()
		}
	}()
	return e.Child.Render()
}
Mount
Method

Mount mounts the child component, updating the DOM with the fallback HTML if the child panics during mounting.

func (*ErrorBoundary) Mount()
{
	if e.err != nil {
		e.mounted = true
		return
	}
	defer func() {
		if r := recover(); r != nil {
			e.err = r
			e.mounted = true
			ReportError(r, "Boundary mount: "+e.Child.GetName())
			dom.UpdateDOM(e.Child.GetID(), e.Fallback)
		}
	}()
	e.Child.Mount()
	e.mounted = true
}
Unmount
Method

Unmount delegates to the child component's Unmount method.

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

OnMount is a no-op for ErrorBoundary.

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

OnUnmount is a no-op for ErrorBoundary.

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

GetName returns the name of the component.

Returns

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

GetID returns the wrapped child's ID.

Returns

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

SetSlots delegates slot assignment to the child component.

Parameters

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

IsMounted reports whether the boundary or its fallback is mounted.

Returns

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

OnParams delegates route parameters to the wrapped component.

Parameters

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

Fields

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

NewErrorBoundary

NewErrorBoundary creates a new ErrorBoundary around the provided child
component. If the child panics during Render or Mount, the provided fallback
HTML will be rendered instead.

Parameters

child
fallback
string

Returns

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

{
	return &ErrorBoundary{Child: child, Fallback: fallback}
}
F
function

TestDependencyRenderFollowsStore

A dependency bound to a store must not be frozen by the parent’s render
cache: the cache key knows nothing about store state, so re-rendering the
parent has to re-render the included subtree too.

Parameters

core/render_cache_dependency_test.go:15-42
func TestDependencyRenderFollowsStore(t *testing.T)

{
	store := state.NewStore("depcache", state.WithModule("app"))
	store.Set("chrome", "on")
	defer state.GlobalStoreManager.UnregisterStore("app", "depcache")

	child := NewHTMLComponent("CacheChild", []byte(`<root>
@if:store:app.depcache.chrome == "on"
<span id="child-block">visible</span>
@endif
</root>`), nil)
	child.SetComponent(child)
	child.Init(nil)

	parent := NewHTMLComponent("CacheParent", []byte(`<root><div>@include:child</div></root>`), nil)
	parent.SetComponent(parent)
	parent.AddDependency("child", child)
	parent.Init(nil)

	if html := parent.Render(); !strings.Contains(html, "child-block") {
		t.Fatalf("first render missed the true branch: %s", html)
	}

	store.Set("chrome", "off")

	if html := parent.RenderFresh(); strings.Contains(html, "child-block") {
		t.Fatalf("dependency kept its cached render after the store changed: %s", html)
	}
}
I
interface

Node

Node renders a parsed template node.

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

Methods

Render
Method

Parameters

Returns

string
func Render(...)
S
struct

TextNode

TextNode contains literal template text.

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

Methods

Render
Method

Render returns the literal text.

Parameters

Returns

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

Fields

Name Type Description
Text string
S
struct

ConditionalBranch

ConditionalBranch contains a conditional expression and its nodes.

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

Fields

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

ConditionalNode

ConditionalNode renders the first matching branch.

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

Methods

Render
Method

Render evaluates the conditional branches and renders the appropriate content.

Parameters

Returns

string
func (*ConditionalNode) Render(c *HTMLComponent) string
{
	var conditions []string
	for _, br := range cn.Branches {
		conditions = append(conditions, br.Condition)
	}
	conditionHash := sha256.Sum256([]byte(strings.Join(conditions, "|")))
	conditionID := fmt.Sprintf("cond-%x-%d", conditionHash[:20], c.condSeq)
	c.condSeq++

	var content ConditionContent
	var chosen string
	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
S
struct

ConditionalBranchContent

ConditionalBranchContent stores rendered content for one branch.

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

Fields

Name Type Description
Condition string
Content string
S
struct

ConditionContent

ConditionContent stores all rendered branches of a conditional block.

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

Fields

Name Type Description
Branches []ConditionalBranchContent
S
struct

ConditionDependency

ConditionDependency identifies reactive state read by a condition.

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

Fields

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

replaceIncludePlaceholders

Parameters

renderedTemplate
string

Returns

string
core/rtml.go:165-177
func replaceIncludePlaceholders(c *HTMLComponent, renderedTemplate string) string

{
	includeRegex := reInclude
	return includeRegex.ReplaceAllStringFunc(renderedTemplate, func(match string) string {
		name := includeRegex.FindStringSubmatch(match)[1]
		if dep, ok := c.Dependencies[name]; ok {
			return dep.Render()
		}
		if DevMode {
			Log().Warn("component %s missing dependency '%s'", c.Name, name)
		}
		return match
	})
}
F
function

replaceComponentIncludes

replaceComponentIncludes scans for @include directives that supply inline
props using the syntax @include:Component:{key:“value”}. Matching includes
are replaced with standard @include placeholders after instantiating the
component and registering it as a dependency.

Parameters

template
string

Returns

string
core/rtml.go:183-225
func replaceComponentIncludes(template string, c *HTMLComponent) string

{
	idx := 0

	// Handle includes that may be wrapped in <p> tags produced by Markdown
	// renderers as well as bare @include directives.
	patterns := []string{
		`<p>@include:([\w-]+):\{([^}]*)\}</p>`,
		`@include:([\w-]+):\{([^}]*)\}`,
	}

	for _, pat := range patterns {
		re := regexp.MustCompile(pat)
		template = re.ReplaceAllStringFunc(template, func(match string) string {
			parts := re.FindStringSubmatch(match)
			if len(parts) < 3 {
				return match
			}
			name := parts[1]
			propStr := html.UnescapeString(parts[2])
			comp := LoadComponent(name)
			if comp == nil {
				if DevMode {
					Log().Warn("include referenced unknown component '%s'", name)
				}
				return match
			}
			props := map[string]any{}
			propRe := rePropKV
			for _, m := range propRe.FindAllStringSubmatch(propStr, -1) {
				props[m[1]] = m[2]
			}
			if hc, ok := comp.(*HTMLComponent); ok {
				hc.Props = props
			}
			placeholder := fmt.Sprintf("inc-%s-%d", name, idx)
			idx++
			c.AddDependency(placeholder, comp)
			return "@include:" + placeholder
		})
	}

	return template
}
F
function

extractSlotContents

Parameters

template
string

Returns

string
core/rtml.go:227-249
func extractSlotContents(template string, c *HTMLComponent) string

{
	slotRegex := reSlotNamed
	return slotRegex.ReplaceAllStringFunc(template, func(match string) string {
		parts := slotRegex.FindStringSubmatch(match)
		if len(parts) < 4 {
			return match
		}
		depName := parts[1]
		slotName := parts[2]
		if slotName == "" {
			slotName = "default"
		}
		content := parts[3]
		if dep, ok := c.Dependencies[depName]; ok {
			dep.SetSlots(map[string]any{slotName: content})
			return ""
		}
		if DevMode {
			Log().Warn("component %s missing dependency '%s' for slot '%s'", c.Name, depName, slotName)
		}
		return match
	})
}
F
function

replaceSlotPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:251-279
func replaceSlotPlaceholders(template string, c *HTMLComponent) string

{
	slotRegex := reSlotDefault
	idx := 0
	return slotRegex.ReplaceAllStringFunc(template, func(match string) string {
		parts := slotRegex.FindStringSubmatch(match)
		if len(parts) < 3 {
			return match
		}
		slotName := parts[1]
		if slotName == "" {
			slotName = "default"
		}
		fallback := parts[2]
		if content, ok := c.Slots[slotName]; ok {
			switch v := content.(type) {
			case string:
				return v
			case Component:
				placeholder := fmt.Sprintf("slot-%s-%d", slotName, idx)
				idx++
				c.AddDependency(placeholder, v)
				return fmt.Sprintf("@include:%s", placeholder)
			default:
				return fallback
			}
		}
		return fallback
	})
}
F
function

escapeValue

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

Parameters

v
any

Returns

string
core/rtml.go:283-285
func escapeValue(v any) string

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

replaceStorePlaceholders

Parameters

template
string

Returns

string
core/rtml.go:287-342
func replaceStorePlaceholders(template string, c *HTMLComponent) string

{
	template = reRawStore.ReplaceAllStringFunc(template, func(match string) string {
		parts := reRawStore.FindStringSubmatch(match)
		if len(parts) < 4 {
			return match
		}
		module, storeName, key := parts[1], parts[2], parts[3]
		store := state.GlobalStoreManager.GetStore(module, storeName)
		if store == nil {
			return match
		}
		value := store.Get(key)
		if value == nil {
			value = ""
		}
		unsubscribe := store.OnChange(key, func(newValue any) {
			updateStoreBindings(c, module, storeName, key, newValue)
		})
		c.unsubscribes.Add(unsubscribe)
		return fmt.Sprintf(`<span data-store-raw="%s.%s.%s">%v</span>`, module, storeName, key, value)
	})
	storeRegex := reStore
	return storeRegex.ReplaceAllStringFunc(template, func(match string) string {
		parts := storeRegex.FindStringSubmatch(match)
		if len(parts) < 4 {
			return match
		}

		module := parts[1]
		storeName := parts[2]
		key := parts[3]
		isWriteable := len(parts) == 5 && parts[4] == ":w"

		store := state.GlobalStoreManager.GetStore(module, storeName)
		if store != nil {
			value := store.Get(key)
			if value == nil {
				value = ""
			}

			unsubscribe := store.OnChange(key, func(newValue any) {
				updateStoreBindings(c, module, storeName, key, newValue)
			})
			c.unsubscribes.Add(unsubscribe)

			if isWriteable {
				return match
			}
			return fmt.Sprintf(`<span data-store="%s.%s.%s">%s</span>`, module, storeName, key, escapeValue(value))
		}
		if DevMode {
			Log().Warn("store %s.%s not found for key '%s' in component %s", module, storeName, key, c.Name)
		}
		return match
	})
}
F
function

replaceSignalPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:344-374
func replaceSignalPlaceholders(template string, c *HTMLComponent) string

{
	sigRegex := reSignal
	return sigRegex.ReplaceAllStringFunc(template, func(match string) string {
		parts := sigRegex.FindStringSubmatch(match)
		if len(parts) < 2 {
			return match
		}
		name := parts[1]
		isWriteable := len(parts) == 3 && parts[2] == ":w"
		if prop, ok := c.Props[name]; ok {
			if sig, ok := prop.(interface{ Read() any }); ok {
				dom.RegisterSignal(c.ID, name, sig)
				val := sig.Read()
				unsub := state.Effect(func() func() {
					v := sig.Read()
					updateSignalBindings(c, name, v)
					return nil
				})
				c.unsubscribes.Add(unsub)
				if isWriteable {
					return match
				}
				return fmt.Sprintf(`<span data-signal="%s">%s</span>`, name, escapeValue(val))
			}
		}
		if DevMode {
			Log().Warn("signal '%s' not found in component %s", name, c.Name)
		}
		return match
	})
}
F
function

replaceExprPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:376-404
func replaceExprPlaceholders(template string, c *HTMLComponent) string

{
	exprRegex := reExpr
	idx := 0
	return exprRegex.ReplaceAllStringFunc(template, func(match string) string {
		parts := exprRegex.FindStringSubmatch(match)
		if len(parts) < 2 {
			return match
		}

		exprStr := strings.TrimSpace(parts[1])
		exprID := fmt.Sprintf("expr-%d", idx)
		idx++

		astExpr := rtmlast.ParseExpr(exprStr)
		initialVal := evalASTExprWithSigRefs(astExpr, c, nil)
		c.exprContents[exprID] = exprToString(astExpr)

		sigRefs := collectExprSignals(astExpr, c)

		unsub := state.Effect(func() func() {
			newVal := evalASTExprWithSigRefs(astExpr, c, sigRefs)
			updateExprBindings(c, exprID, newVal)
			return nil
		})
		c.unsubscribes.Add(unsub)

		return fmt.Sprintf(`<span data-expr="%s">%s</span>`, exprID, escapeValue(initialVal))
	})
}
F
function

replaceExprInClassAttr

Parameters

template
string

Returns

string
core/rtml.go:406-453
func replaceExprInClassAttr(template string, c *HTMLComponent) string

{
	classRe := regexp.MustCompile(`class="([^"]*@expr:[^"]*)"`)
	idx := 0
	result := classRe.ReplaceAllStringFunc(template, func(match string) string {
		parts := classRe.FindStringSubmatch(match)
		if len(parts) < 2 {
			return match
		}
		classVal := parts[1]

		exprInAttrRe := regexp.MustCompile(`@expr:((?:[^"<@]|'[^']*')+)`)
		var exprIDs []string
		newClassVal := exprInAttrRe.ReplaceAllStringFunc(classVal, func(exprMatch string) string {
			eparts := exprInAttrRe.FindStringSubmatch(exprMatch)
			if len(eparts) < 2 {
				return exprMatch
			}
			exprStr := strings.TrimSpace(eparts[1])
			if exprStr == "" || len(exprStr) == 1 && (exprStr[0] == '\'' || exprStr[0] == '"') {
				return exprMatch
			}
			exprID := fmt.Sprintf("class-expr-%d", idx)
			idx++
			exprIDs = append(exprIDs, exprID)

			astExpr := rtmlast.ParseExpr(exprStr)
			initialVal := evalASTExprWithSigRefs(astExpr, c, nil)
			dynamicVal := strings.TrimSpace(fmt.Sprintf("%v", initialVal))
			c.classExprContents[exprID] = dynamicVal
			c.exprContents[exprID] = exprToString(astExpr)

			sigRefs := collectExprSignals(astExpr, c)

			unsub := state.Effect(func() func() {
				newVal := evalASTExprWithSigRefs(astExpr, c, sigRefs)
				updateClassExprBindings(c, exprID, newVal)
				return nil
			})
			c.unsubscribes.Add(unsub)

			return dynamicVal
		})

		idsStr := strings.Join(exprIDs, " ")
		return fmt.Sprintf(`class="%s" data-expr-class="%s"`, newClassVal, idsStr)
	})
	return result
}
F
function

collectExprSignals

Parameters

Returns

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

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

collectIdents

Parameters

refs
map[string]any
core/rtml.go:461-485
func collectIdents(expr rtmlast.Expr, c *HTMLComponent, refs map[string]any)

{
	switch e := expr.(type) {
	case rtmlast.IdentExpr:
		name := e.Name
		if strings.HasPrefix(name, "store:") || strings.HasPrefix(name, "signal:") {
			return
		}
		if _, seen := refs[name]; !seen {
			if prop, ok := c.Props[name]; ok {
				refs[name] = prop
			}
		}
	case rtmlast.BinaryExpr:
		collectIdents(e.LHS, c, refs)
		collectIdents(e.RHS, c, refs)
	case rtmlast.UnaryExpr:
		collectIdents(e.Expr, c, refs)
	case rtmlast.FieldExpr:
		collectIdents(e.Obj, c, refs)
	case rtmlast.TernaryExpr:
		collectIdents(e.Cond, c, refs)
		collectIdents(e.Then, c, refs)
		collectIdents(e.Else, c, refs)
	}
}
F
function

evalASTExprWithSigRefs

Parameters

sigRefs
map[string]any

Returns

any
core/rtml.go:487-576
func evalASTExprWithSigRefs(expr rtmlast.Expr, c *HTMLComponent, sigRefs map[string]any) any

{
	switch e := expr.(type) {
	case rtmlast.IdentExpr:
		name := e.Name
		if strings.HasPrefix(name, "store:") {
			parts := strings.Split(strings.TrimPrefix(name, "store:"), ".")
			if len(parts) == 3 {
				store := state.GlobalStoreManager.GetStore(parts[0], parts[1])
				if store != nil {
					return store.Get(parts[2])
				}
			}
			return nil
		}
		if strings.HasPrefix(name, "signal:") {
			sigName := strings.TrimPrefix(name, "signal:")
			if prop, ok := c.Props[sigName]; ok {
				if sig, ok := prop.(interface{ Read() any }); ok {
					return sig.Read()
				}
				return prop
			}
			return nil
		}
		if prop, ok := sigRefs[name]; ok {
			if sig, ok := prop.(interface{ Read() any }); ok {
				return sig.Read()
			}
			return prop
		}
		if prop, ok := c.Props[name]; ok {
			if sig, ok := prop.(interface{ Read() any }); ok {
				return sig.Read()
			}
			return prop
		}
		return nil
	case rtmlast.LiteralExpr:
		return e.Value
	case rtmlast.BinaryExpr:
		switch e.Op {
		case rtmlast.OpEq:
			return cmpASTEqual(evalASTExprWithSigRefs(e.LHS, c, sigRefs), evalASTExprWithSigRefs(e.RHS, c, sigRefs))
		case rtmlast.OpNeq:
			return !cmpASTEqual(evalASTExprWithSigRefs(e.LHS, c, sigRefs), evalASTExprWithSigRefs(e.RHS, c, sigRefs))
		case rtmlast.OpAnd:
			return toASTBool(evalASTExprWithSigRefs(e.LHS, c, sigRefs)) && toASTBool(evalASTExprWithSigRefs(e.RHS, c, sigRefs))
		case rtmlast.OpOr:
			return toASTBool(evalASTExprWithSigRefs(e.LHS, c, sigRefs)) || toASTBool(evalASTExprWithSigRefs(e.RHS, c, sigRefs))
		case rtmlast.OpLt, rtmlast.OpGt, rtmlast.OpLte, rtmlast.OpGte:
			return cmpASTValues(evalASTExprWithSigRefs(e.LHS, c, sigRefs), evalASTExprWithSigRefs(e.RHS, c, sigRefs), e.Op)
		default:
			lhs := toASTFloat(evalASTExprWithSigRefs(e.LHS, c, sigRefs))
			rhs := toASTFloat(evalASTExprWithSigRefs(e.RHS, c, sigRefs))
			switch e.Op {
			case rtmlast.OpAdd:
				return lhs + rhs
			case rtmlast.OpSub:
				return lhs - rhs
			case rtmlast.OpMul:
				return lhs * rhs
			case rtmlast.OpDiv:
				if rhs == 0 {
					return 0.0
				}
				return lhs / rhs
			}
		}
	case rtmlast.UnaryExpr:
		val := evalASTExprWithSigRefs(e.Expr, c, sigRefs)
		switch e.Op {
		case rtmlast.UnaryNot:
			return !toASTBool(val)
		case rtmlast.UnaryNeg:
			return -toASTFloat(val)
		}
	case rtmlast.FieldExpr:
		obj := evalASTExprWithSigRefs(e.Obj, c, sigRefs)
		if m, ok := obj.(map[string]any); ok {
			return m[e.Field]
		}
		return nil
	case rtmlast.TernaryExpr:
		if toASTBool(evalASTExprWithSigRefs(e.Cond, c, sigRefs)) {
			return evalASTExprWithSigRefs(e.Then, c, sigRefs)
		}
		return evalASTExprWithSigRefs(e.Else, c, sigRefs)
	}
	return nil
}
F
function

cmpASTEqual

Parameters

a
any
b
any

Returns

bool
core/rtml.go:578-603
func cmpASTEqual(a, b any) bool

{
	switch av := a.(type) {
	case string:
		if bv, ok := b.(string); ok {
			return av == bv
		}
	case bool:
		if bv, ok := b.(bool); ok {
			return av == bv
		}
	case int:
		if bv, ok := b.(int); ok {
			return av == bv
		}
	case float64:
		if bv, ok := b.(float64); ok {
			return av == bv
		}
	}
	af, aok := toASTFloatOk(a)
	bf, bok := toASTFloatOk(b)
	if aok && bok {
		return af == bf
	}
	return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
F
function

cmpASTValues

Parameters

a
any
b
any

Returns

bool
core/rtml.go:605-619
func cmpASTValues(a, b any, op rtmlast.BinOp) bool

{
	l, r := toASTFloat(a), toASTFloat(b)
	switch op {
	case rtmlast.OpLt:
		return l < r
	case rtmlast.OpGt:
		return l > r
	case rtmlast.OpLte:
		return l <= r
	case rtmlast.OpGte:
		return l >= r
	default:
		return false
	}
}
F
function

toASTFloat

Parameters

v
any

Returns

float64
core/rtml.go:621-640
func toASTFloat(v any) float64

{
	switch val := v.(type) {
	case int:
		return float64(val)
	case int64:
		return float64(val)
	case float64:
		return val
	case float32:
		return float64(val)
	case string:
		f, err := strconv.ParseFloat(val, 64)
		if err != nil {
			return 0
		}
		return f
	default:
		return 0
	}
}
F
function

toASTFloatOk

Parameters

v
any

Returns

float64
bool
core/rtml.go:642-655
func toASTFloatOk(v any) (float64, bool)

{
	switch val := v.(type) {
	case int:
		return float64(val), true
	case int64:
		return float64(val), true
	case float64:
		return val, true
	case float32:
		return float64(val), true
	default:
		return 0, false
	}
}
F
function

toASTBool

Parameters

v
any

Returns

bool
core/rtml.go:657-670
func toASTBool(v any) bool

{
	switch val := v.(type) {
	case bool:
		return val
	case string:
		return val != ""
	case int:
		return val != 0
	case float64:
		return val != 0
	default:
		return v != nil
	}
}
F
function

exprToString

Parameters

Returns

string
core/rtml.go:672-695
func exprToString(expr rtmlast.Expr) string

{
	switch e := expr.(type) {
	case rtmlast.IdentExpr:
		return e.Name
	case rtmlast.LiteralExpr:
		return fmt.Sprintf("%v", e.Value)
	case rtmlast.BinaryExpr:
		return fmt.Sprintf("(%s %s %s)", exprToString(e.LHS), binOpString(e.Op), exprToString(e.RHS))
	case rtmlast.UnaryExpr:
		switch e.Op {
		case rtmlast.UnaryNot:
			return fmt.Sprintf("!%s", exprToString(e.Expr))
		case rtmlast.UnaryNeg:
			return fmt.Sprintf("-%s", exprToString(e.Expr))
		}
	case rtmlast.FieldExpr:
		return fmt.Sprintf("%s.%s", exprToString(e.Obj), e.Field)
	case rtmlast.CallExpr:
		return fmt.Sprintf("%s(%v)", e.Fn, e.Args)
	case rtmlast.TernaryExpr:
		return fmt.Sprintf("%s ? %s : %s", exprToString(e.Cond), exprToString(e.Then), exprToString(e.Else))
	}
	return ""
}
F
function

binOpString

Parameters

Returns

string
core/rtml.go:697-726
func binOpString(op rtmlast.BinOp) string

{
	switch op {
	case rtmlast.OpEq:
		return "=="
	case rtmlast.OpNeq:
		return "!="
	case rtmlast.OpLt:
		return "<"
	case rtmlast.OpGt:
		return ">"
	case rtmlast.OpLte:
		return "<="
	case rtmlast.OpGte:
		return ">="
	case rtmlast.OpAnd:
		return "&&"
	case rtmlast.OpOr:
		return "||"
	case rtmlast.OpAdd:
		return "+"
	case rtmlast.OpSub:
		return "-"
	case rtmlast.OpMul:
		return "*"
	case rtmlast.OpDiv:
		return "/"
	default:
		return "?"
	}
}
F
function

updateExprBindings

Parameters

exprID
string
newValue
any
core/rtml.go:728-740
func updateExprBindings(c *HTMLComponent, exprID string, newValue any)

{
	element := dom.ComponentRoot(c.ID)
	if element.IsNull() || element.IsUndefined() {
		return
	}

	selector := fmt.Sprintf(`[data-expr="%s"]`, exprID)
	nodes := element.Call("querySelectorAll", selector)
	for i := 0; i < nodes.Length(); i++ {
		node := nodes.Index(i)
		node.Set("textContent", fmt.Sprintf("%v", newValue))
	}
}
F
function

updateClassExprBindings

Parameters

exprID
string
newValue
any
core/rtml.go:742-770
func updateClassExprBindings(c *HTMLComponent, exprID string, newValue any)

{
	element := dom.ComponentRoot(c.ID)
	if element.IsNull() || element.IsUndefined() {
		return
	}

	selector := fmt.Sprintf(`[data-expr-class="%s"]`, exprID)
	nodes := element.Call("querySelectorAll", selector)
	for i := 0; i < nodes.Length(); i++ {
		node := nodes.Index(i)
		newClassVal := strings.TrimSpace(fmt.Sprintf("%v", newValue))
		oldClassVal, ok := c.classExprContents[exprID]
		if !ok {
			return
		}
		c.classExprContents[exprID] = newClassVal

		currentClass := node.Get("className").String()
		if currentClass == "" {
			node.Set("className", newClassVal)
		} else {
			replaced := strings.Replace(currentClass, oldClassVal, newClassVal, 1)
			if replaced == currentClass && oldClassVal != "" && newClassVal != "" {
				replaced = currentClass + " " + newClassVal
			}
			node.Set("className", replaced)
		}
	}
}
F
function

replacePropPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:772-807
func replacePropPlaceholders(template string, c *HTMLComponent) string

{
	template = reRawProp.ReplaceAllStringFunc(template, func(match string) string {
		parts := reRawProp.FindStringSubmatch(match)
		if len(parts) != 2 {
			return match
		}
		if value, exists := c.Props[parts[1]]; exists {
			return fmt.Sprintf("%v", value)
		}
		return match
	})
	propRegex := reProp
	idx := 0
	return propRegex.ReplaceAllStringFunc(template, func(match string) string {
		parts := propRegex.FindStringSubmatch(match)
		if len(parts) != 2 {
			return match
		}
		propName := parts[1]
		if value, exists := c.Props[propName]; exists {
			switch v := value.(type) {
			case Component:
				placeholder := fmt.Sprintf("prop-%s-%d", propName, idx)
				idx++
				c.AddDependency(placeholder, v)
				return fmt.Sprintf("@include:%s", placeholder)
			default:
				return escapeValue(v)
			}
		}
		if DevMode {
			Log().Warn("component %s missing prop '%s'", c.Name, propName)
		}
		return match
	})
}
F
function

replacePluginPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:809-835
func replacePluginPlaceholders(template string) string

{
	varRegex := rePluginVar
	template = varRegex.ReplaceAllStringFunc(template, func(match string) string {
		parts := varRegex.FindStringSubmatch(match)
		if len(parts) != 3 {
			return match
		}
		plug, name := parts[1], parts[2]
		if v, ok := getRTMLVar(plug, name); ok {
			return fmt.Sprintf("%v", v)
		}
		if DevMode {
			Log().Warn("plugin variable %s.%s not found", plug, name)
		}
		return match
	})
	cmdRegex := rePluginCmd
	template = cmdRegex.ReplaceAllStringFunc(template, func(match string) string {
		parts := cmdRegex.FindStringSubmatch(match)
		if len(parts) != 4 {
			return match
		}
		plug, name, suffix := parts[1], parts[2], parts[3]
		return fmt.Sprintf(`data-plugin-cmd="%s.%s"%s`, plug, name, suffix)
	})
	return template
}
F
function

replaceHostPlaceholders

Parameters

template
string

Returns

string
core/rtml.go:837-864
func replaceHostPlaceholders(template string, c *HTMLComponent) string

{
	varRegex := reHelperVar
	template = varRegex.ReplaceAllStringFunc(template, func(match string) string {
		name := varRegex.FindStringSubmatch(match)[1]
		c.hostVars = append(c.hostVars, name)

		expectedVal := ""
		if c.Props != nil {
			if v, ok := c.Props[name]; ok {
				expectedVal = fmt.Sprintf("%v", v)
			} else if v, ok := c.Props["h:"+name]; ok {
				expectedVal = fmt.Sprintf("%v", v)
			}
		}

		expectedAttr := html.EscapeString(expectedVal)

		return fmt.Sprintf(`<span data-host-var="%s" data-host-expected="%s">%s</span>`,
			name, expectedAttr, html.EscapeString(expectedVal))
	})
	cmdRegex := reHelperCmd
	template = cmdRegex.ReplaceAllStringFunc(template, func(match string) string {
		name := cmdRegex.FindStringSubmatch(match)[1]
		c.hostCmds = append(c.hostCmds, name)
		return fmt.Sprintf(`data-host-cmd="%s"`, name)
	})
	return template
}
F
function

replaceEventHandlers

Parameters

template
string

Returns

string
core/rtml.go:866-868
func replaceEventHandlers(template string) string

{
	return dom.ExpandEvents(template)
}
F
function

replaceRtIsAttributes

replaceRtIsAttributes scans the template for elements decorated with the
rt-is attribute. The attribute’s value identifies a component registered in
the ComponentRegistry. Matching elements are replaced with an @include
placeholder so standard include processing can render the referenced
component and manage its lifecycle.

Parameters

template
string

Returns

string
core/rtml.go:875-896
func replaceRtIsAttributes(template string, c *HTMLComponent) string

{
	re := reRtIs
	idx := 0
	return re.ReplaceAllStringFunc(template, func(match string) string {
		parts := re.FindStringSubmatch(match)
		if len(parts) < 4 {
			return match
		}
		name := parts[3]
		comp := LoadComponent(name)
		if comp == nil {
			if DevMode {
				Log().Warn("rt-is referenced unknown component '%s'", name)
			}
			return match
		}
		placeholder := fmt.Sprintf("rtis-%s-%d", name, idx)
		idx++
		c.AddDependency(placeholder, comp)
		return fmt.Sprintf("@include:%s", placeholder)
	})
}
F
function

parseTemplate

parseTemplate parses the template string into an AST of nodes.

Parameters

template
string

Returns

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

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

parseBlock

Parameters

lines
[]string
idx
*int

Returns

error
core/rtml.go:905-927
func parseBlock(lines []string, idx *int) ([]Node, error)

{
	var nodes []Node
	for *idx < len(lines) {
		line := lines[*idx]
		trimmed := strings.TrimSpace(line)
		switch {
		case strings.HasPrefix(trimmed, "@if:"):
			cond := trimmed
			*idx++
			n, err := parseConditional(lines, idx, cond)
			if err != nil {
				return nil, err
			}
			nodes = append(nodes, n)
		case strings.HasPrefix(trimmed, "@else-if:"), trimmed == "@else", trimmed == "@endif":
			return nodes, nil
		default:
			nodes = append(nodes, &TextNode{Text: line + "\n"})
			*idx++
		}
	}
	return nodes, nil
}
F
function

parseConditional

Parameters

lines
[]string
idx
*int
firstCond
string

Returns

error
core/rtml.go:929-963
func parseConditional(lines []string, idx *int, firstCond string) (Node, error)

{
	node := &ConditionalNode{}
	children, err := parseBlock(lines, idx)
	if err != nil {
		return nil, err
	}
	node.Branches = append(node.Branches, ConditionalBranch{Condition: firstCond, Nodes: children})

	for *idx < len(lines) {
		trimmed := strings.TrimSpace(lines[*idx])
		switch {
		case strings.HasPrefix(trimmed, "@else-if:"):
			cond := trimmed
			*idx++
			children, err := parseBlock(lines, idx)
			if err != nil {
				return nil, err
			}
			node.Branches = append(node.Branches, ConditionalBranch{Condition: cond, Nodes: children})
		case trimmed == "@else":
			*idx++
			children, err := parseBlock(lines, idx)
			if err != nil {
				return nil, err
			}
			node.Branches = append(node.Branches, ConditionalBranch{Condition: "", Nodes: children})
		case trimmed == "@endif":
			*idx++
			return node, nil
		default:
			*idx++
		}
	}
	return node, nil
}
F
function

replaceConditionals

replaceConditionals parses conditionals using the AST and renders them.

Parameters

template
string

Returns

string
core/rtml.go:966-979
func replaceConditionals(template string, c *HTMLComponent) string

{
	nodes, err := parseTemplate(template)
	if err != nil {
		return template
	}
	// the ids are positional: restart the numbering so a re-render maps every
	// block back onto the node it painted before
	c.condSeq = 0
	var sb strings.Builder
	for _, n := range nodes {
		sb.WriteString(n.Render(c))
	}
	return sb.String()
}
F
function

evaluateCondition

Parameters

condition
string

Returns

core/rtml.go:981-1031
func evaluateCondition(condition string, c *HTMLComponent) (bool, []ConditionDependency)

{
	expr := condition
	expr = strings.TrimPrefix(expr, "@if:")
	expr = strings.TrimPrefix(expr, "@else-if:")
	expr = strings.TrimSpace(expr)

	dependencies := extractDependencies(expr)

	lookup := func(name string) (any, bool) {
		if strings.HasPrefix(name, "store:") {
			parts := strings.Split(strings.TrimPrefix(name, "store:"), ".")
			if len(parts) == 3 {
				store := state.GlobalStoreManager.GetStore(parts[0], parts[1])
				if store != nil {
					return store.Get(parts[2]), true
				}
			}
			return nil, false
		}
		if strings.HasPrefix(name, "signal:") {
			sigName := strings.TrimPrefix(name, "signal:")
			if prop, ok := c.Props[sigName]; ok {
				if sig, ok := prop.(interface{ Read() any }); ok {
					return sig.Read(), true
				}
			}
			return nil, false
		}
		if strings.HasPrefix(name, "prop:") {
			propName := strings.TrimPrefix(name, "prop:")
			if v, ok := c.Props[propName]; ok {
				return v, true
			}
			return nil, false
		}
		if v, ok := c.Props[name]; ok {
			if sig, ok := v.(interface{ Read() any }); ok {
				return sig.Read(), true
			}
			return v, true
		}
		return nil, false
	}

	result, err := rtmleval.Bool(expr, lookup)
	if err != nil {
		Log().Debug("Condition evaluation error: %v", err)
		return false, dependencies
	}
	return result, dependencies
}
F
function

extractDependencies

Parameters

expr
string
core/rtml.go:1033-1047
func extractDependencies(expr string) []ConditionDependency

{
	var deps []ConditionDependency
	fields := depRegex.FindAllString(expr, -1)
	for _, f := range fields {
		if strings.HasPrefix(f, "store:") {
			parts := strings.Split(strings.TrimPrefix(f, "store:"), ".")
			if len(parts) == 3 {
				deps = append(deps, ConditionDependency{module: parts[0], storeName: parts[1], key: parts[2]})
			}
		} else if strings.HasPrefix(f, "signal:") {
			deps = append(deps, ConditionDependency{signal: strings.TrimPrefix(f, "signal:")})
		}
	}
	return deps
}
F
function

updateStoreBindings

Parameters

module
string
storeName
string
key
string
newValue
any
core/rtml.go:1049-1101
func updateStoreBindings(c *HTMLComponent, module, storeName, key string, newValue any)

{
	element := dom.ComponentRoot(c.ID)
	if element.IsNull() || element.IsUndefined() {
		return
	}

	selector := fmt.Sprintf(`[data-store="%s.%s.%s"]`, module, storeName, key)
	nodes := element.Call("querySelectorAll", selector)
	for i := 0; i < nodes.Length(); i++ {
		nodes.Index(i).Set("textContent", fmt.Sprintf("%v", newValue))
	}
	rawSelector := fmt.Sprintf(`[data-store-raw="%s.%s.%s"]`, module, storeName, key)
	rawNodes := element.Call("querySelectorAll", rawSelector)
	for i := 0; i < rawNodes.Length(); i++ {
		rawNodes.Index(i).Set("innerHTML", fmt.Sprintf("%v", newValue))
	}

	placeholder := fmt.Sprintf("@store:%s.%s.%s:w", module, storeName, key)

	// Update value-based inputs and selects
	inputSelector := fmt.Sprintf(`input[value="%s"], select[value="%s"]`, placeholder, placeholder)
	inputs := element.Call("querySelectorAll", inputSelector)
	for i := 0; i < inputs.Length(); i++ {
		input := inputs.Index(i)
		input.Set("value", fmt.Sprintf("%v", newValue))
	}

	// Update checkboxes bound via checked attribute
	checkedSelector := fmt.Sprintf(`input[checked="%s"]`, placeholder)
	checks := element.Call("querySelectorAll", checkedSelector)
	for i := 0; i < checks.Length(); i++ {
		chk := checks.Index(i)
		switch v := newValue.(type) {
		case bool:
			chk.Set("checked", v)
		case string:
			chk.Set("checked", strings.ToLower(v) == "true")
		default:
			chk.Set("checked", newValue != nil)
		}
	}

	// Update textareas where placeholder is in content
	textareas := element.Call("querySelectorAll", "textarea")
	for i := 0; i < textareas.Length(); i++ {
		ta := textareas.Index(i)
		if ta.Get("value").String() == placeholder {
			ta.Set("value", fmt.Sprintf("%v", newValue))
		}
	}

	updateConditionsForStoreVariable(c, module, storeName, key)
}
F
function

updateSignalBindings

Parameters

name
string
newValue
any
core/rtml.go:1103-1149
func updateSignalBindings(c *HTMLComponent, name string, newValue any)

{
	element := dom.ComponentRoot(c.ID)
	if element.IsNull() || element.IsUndefined() {
		return
	}

	selector := fmt.Sprintf(`[data-signal="%s"]`, name)
	nodes := element.Call("querySelectorAll", selector)
	for i := 0; i < nodes.Length(); i++ {
		node := nodes.Index(i)
		node.Set("textContent", fmt.Sprintf("%v", newValue))
	}

	placeholder := fmt.Sprintf("@signal:%s:w", name)

	// Update value-based inputs and selects
	inputSelector := fmt.Sprintf(`input[value="%s"], select[value="%s"]`, placeholder, placeholder)
	inputs := element.Call("querySelectorAll", inputSelector)
	for i := 0; i < inputs.Length(); i++ {
		input := inputs.Index(i)
		input.Set("value", fmt.Sprintf("%v", newValue))
	}

	// Update checkboxes
	checkedSelector := fmt.Sprintf(`input[checked="%s"]`, placeholder)
	checks := element.Call("querySelectorAll", checkedSelector)
	for i := 0; i < checks.Length(); i++ {
		chk := checks.Index(i)
		switch v := newValue.(type) {
		case bool:
			chk.Set("checked", v)
		case string:
			chk.Set("checked", strings.ToLower(v) == "true")
		default:
			chk.Set("checked", newValue != nil)
		}
	}

	// Update textareas with placeholder in content
	textareas := element.Call("querySelectorAll", "textarea")
	for i := 0; i < textareas.Length(); i++ {
		ta := textareas.Index(i)
		if ta.Get("value").String() == placeholder {
			ta.Set("value", fmt.Sprintf("%v", newValue))
		}
	}
}
F
function

insertDataKey

Parameters

content
string
key
any

Returns

string
core/rtml.go:1151-1158
func insertDataKey(content string, key any) string

{
	tagRegex := reTagName
	loc := tagRegex.FindStringSubmatchIndex(content)
	if loc == nil {
		return content
	}
	return content[:loc[1]] + fmt.Sprintf(` data-key="%v"`, key) + content[loc[1]:]
}
F
function

replaceConstructors

replaceConstructors scans for inline constructor tokens inside an element’s
start tag and injects the corresponding data attribute. Supported
constructors:

[name] -> data-ref=“name”
[key expr] -> data-key=“expr”

Only a single constructor per element is handled.

Parameters

template
string

Returns

string
core/rtml.go:1168-1190
func replaceConstructors(template string) string

{
	re := reConditionalAttr
	return re.ReplaceAllStringFunc(template, func(match string) string {
		parts := re.FindStringSubmatch(match)
		if len(parts) < 6 {
			return match
		}
		tag := parts[1]
		before := parts[2]
		name := parts[3]
		param := parts[4]
		after := parts[5]
		attr := ""
		if name == "key" && param != "" {
			attr = fmt.Sprintf(` data-key="%s"`, param)
		} else if strings.HasPrefix(name, "plugin:") {
			attr = fmt.Sprintf(` data-plugin="%s"`, strings.TrimPrefix(name, "plugin:"))
		} else {
			attr = fmt.Sprintf(` data-ref="%s"`, name)
		}
		return fmt.Sprintf("<%s%s%s%s>", tag, before, attr, after)
	})
}
F
function

resolveNumber

Parameters

expr
string

Returns

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

conditionNeedsRender

conditionNeedsRender reports whether any branch of a conditional carries a
binding whose value could have moved while the branch was hidden.

Parameters

conditionID
string

Returns

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

updateConditionBindings

Parameters

conditionID
string
core/rtml.go:1245-1277
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)
}
F
function

updateConditionsForStoreVariable

Parameters

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

getConditionDependencies

Parameters

condition
string

Returns

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

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

reportScopeError

Parameters

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

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

TryRender

TryRender wraps a component’s Render() with panic recovery.
If a panic occurs, it shows the error overlay and returns empty string
so the app stays alive rather than dying to a white screen.

Parameters

Returns

string
core/error_recovery.go:15-22
func TryRender(c Component) string

{
	defer func() {
		if r := recover(); r != nil {
			ReportError(r, fmt.Sprintf("Render: %s (ID: %s)", c.GetName(), c.GetID()))
		}
	}()
	return c.Render()
}
F
function

TryMount

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

Parameters

core/error_recovery.go:25-32
func TryMount(c Component)

{
	defer func() {
		if r := recover(); r != nil {
			ReportError(r, fmt.Sprintf("Mount: %s (ID: %s)", c.GetName(), c.GetID()))
		}
	}()
	c.Mount()
}
F
function

TryUnmount

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

Parameters

core/error_recovery.go:35-42
func TryUnmount(c Component)

{
	defer func() {
		if r := recover(); r != nil {
			ReportError(r, fmt.Sprintf("Unmount: %s (ID: %s)", c.GetName(), c.GetID()))
		}
	}()
	c.Unmount()
}
F
function

TryNavigate

TryNavigate wraps router navigation with panic recovery.

Parameters

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

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

TryEffect

TryEffect wraps an effect function with panic recovery.

Parameters

fn
func() func()

Returns

func()
core/error_recovery.go:55-65
func TryEffect(fn func() func()) func()

{
	return state.Effect(func() func() {
		defer func() {
			if r := recover(); r != nil {
				ReportError(r, "Effect")
				debug.PrintStack()
			}
		}()
		return fn()
	})
}
F
function

TryTemplateLoad

TryTemplateLoad wraps template loading with recovery.

Parameters

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

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

TestAddHostComponentKeepsAllNames

A component may be linked to several host components (one per host field on
a composition struct); registering a second name must not overwrite the
first, and duplicates collapse.

Parameters

core/host_component_test.go:10-23
func TestAddHostComponentKeepsAllNames(t *testing.T)

{
	c := NewHTMLComponent("MultiHost", []byte(`<root></root>`), nil)
	c.AddHostComponent("Counter")
	c.AddHostComponent("Clock")
	c.AddHostComponent("Counter")

	names := c.hostComponentNames()
	if len(names) != 2 || names[0] != "Counter" || names[1] != "Clock" {
		t.Fatalf("unexpected host component names: %v", names)
	}
	if c.HostComponent != "Counter" {
		t.Fatalf("primary host component overwritten: %s", c.HostComponent)
	}
}
F
function

TestHostComponentFieldFallback

Directly assigning the exported HostComponent field keeps working.

Parameters

core/host_component_test.go:26-33
func TestHostComponentFieldFallback(t *testing.T)

{
	c := NewHTMLComponent("FieldHost", []byte(`<root></root>`), nil)
	c.HostComponent = "Legacy"
	names := c.hostComponentNames()
	if len(names) != 1 || names[0] != "Legacy" {
		t.Fatalf("unexpected fallback names: %v", names)
	}
}
S
struct

ComponentStats

ComponentStats is a stub for non-wasm builds.

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

Fields

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

ComponentTimelineEntry

ComponentTimelineEntry is a stub for non-wasm builds.

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

Fields

Name Type Description
Kind string
Timestamp time.Time
Duration time.Duration
S
struct
Implements: Plugin Named

namedTestPlugin

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

Methods

Build
Method

Parameters

Returns

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

Parameters

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

Returns

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

Fields

Name Type Description
installed int
F
function

TestRegisterPlugin_dedup

Parameters

core/plugin_test.go:16-31
func TestRegisterPlugin_dedup(t *testing.T)

{
	app = newApp()
	p1 := &namedTestPlugin{}
	RegisterPlugin(p1)
	if p1.installed != 1 {
		t.Fatalf("expected first plugin to install once, got %d", p1.installed)
	}
	p2 := &namedTestPlugin{}
	RegisterPlugin(p2)
	if p2.installed != 0 {
		t.Fatalf("expected second plugin not to install, got %d", p2.installed)
	}
	if !app.HasPlugin("named-test") {
		t.Fatalf("expected HasPlugin to return true")
	}
}
S
struct
Implements: Named Plugin

depPlugin

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

Methods

Build
Method

Parameters

Returns

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

Parameters

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

Returns

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

Fields

Name Type Description
installed int
S
struct
Implements: Named Requires

requiresPlugin

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

Methods

Build
Method

Parameters

Returns

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

Parameters

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

Returns

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

Returns

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

Fields

Name Type Description
dep *depPlugin
F
function

TestRegisterPlugin_requires

Parameters

core/plugin_test.go:46-57
func TestRegisterPlugin_requires(t *testing.T)

{
	app = newApp()
	dep := &depPlugin{}
	req := &requiresPlugin{dep: dep}
	RegisterPlugin(req)
	if dep.installed != 1 {
		t.Fatalf("expected dependency to install, got %d", dep.installed)
	}
	if !app.HasPlugin("dep") || !app.HasPlugin("requires") {
		t.Fatalf("expected both plugins to be registered")
	}
}
S
struct
Implements: Named Optional

optionalPlugin

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

Methods

Build
Method

Parameters

Returns

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

Parameters

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

Returns

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

Returns

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

Fields

Name Type Description
dep *depPlugin
enable bool
F
function

TestRegisterPlugin_optional

Parameters

core/plugin_test.go:74-90
func TestRegisterPlugin_optional(t *testing.T)

{
	app = newApp()
	dep := &depPlugin{}
	opt := &optionalPlugin{dep: dep, enable: true}
	RegisterPlugin(opt)
	if dep.installed != 1 {
		t.Fatalf("expected optional dependency to install")
	}

	app = newApp()
	dep2 := &depPlugin{}
	opt2 := &optionalPlugin{dep: dep2, enable: false}
	RegisterPlugin(opt2)
	if dep2.installed != 0 {
		t.Fatalf("expected disabled optional dependency not to install")
	}
}
I
interface

Plugin

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

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

Methods

Build
Method

Parameters

Returns

error
func Build(...)
Install
Method

Parameters

*App
func Install(...)
I
interface

Named

Named exposes a plugin name.

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

Methods

Name
Method

Returns

string
func Name(...)
I
interface

Requires

Requires lists mandatory plugin dependencies.

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

Methods

Requires
Method

Returns

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

Optional

Optional lists optional plugin dependencies.

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

Methods

Optional
Method

Returns

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

PreBuilder

PreBuilder runs before a build.

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

Methods

PreBuild
Method

Parameters

Returns

error
func PreBuild(...)
I
interface

PostBuilder

PostBuilder runs after a build.

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

Methods

PostBuild
Method

Parameters

Returns

error
func PostBuild(...)
I
interface

Uninstaller

Uninstaller removes plugin resources.

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

Methods

Uninstall
Method

Parameters

*App
func Uninstall(...)
S
struct

App

App is a stub holder for callbacks.

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

Methods

RegisterRouter performs no work outside WASM.

Parameters

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

RegisterStore performs no work outside WASM.

Parameters

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

RegisterLifecycle performs no work outside WASM.

Parameters

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

RegisterTemplate performs no work outside WASM.

Parameters

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

RegisterRTMLVar performs no work outside WASM.

Parameters

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

HasPlugin reports false outside WASM.

Parameters

string

Returns

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

RegisterRouter adds a router navigation hook.

Parameters

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

RegisterStore adds a store mutation hook.

Parameters

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

RegisterTemplate adds a template render hook.

Parameters

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

RegisterLifecycle adds hooks for component mount and unmount.

Parameters

mount func(Component)
unmount func(Component)
func (*App) RegisterLifecycle(mount, unmount func(Component))
{
	if mount != nil {
		a.mountHooks = append(a.mountHooks, mount)
	}
	if unmount != nil {
		a.unmountHooks = append(a.unmountHooks, unmount)
	}
}

RegisterRTMLVar registers a value that can be referenced from RTML as {plugin:NAME.VAR}.

Parameters

plugin string
name string
val any
func (*App) RegisterRTMLVar(plugin, name string, val any)
{
	if a.pluginVars == nil {
		a.pluginVars = make(map[string]map[string]any)
	}
	if _, ok := a.pluginVars[plugin]; !ok {
		a.pluginVars[plugin] = make(map[string]any)
	}
	a.pluginVars[plugin][name] = val
}
HasPlugin
Method

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

Parameters

name string

Returns

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

RegisterPlugin

RegisterPlugin performs no work outside WASM.

Parameters

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

{}
F
function

TriggerRouter

TriggerRouter performs no work outside WASM.

Parameters

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

{}
F
function

TriggerStore

TriggerStore performs no work outside WASM.

Parameters

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

{}
F
function

TriggerMount

TriggerMount performs no work outside WASM.

Parameters

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

{}
F
function

TriggerUnmount

TriggerUnmount performs no work outside WASM.

Parameters

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

{}
F
function

TriggerTemplate

TriggerTemplate performs no work outside WASM.

Parameters

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

{}
F
function

OnNavigate

OnNavigate performs no work outside WASM.

Parameters

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

{}
F
function

OnTemplate

OnTemplate performs no work outside WASM.

Parameters

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

{}
F
function

RegisterPluginVar

RegisterPluginVar performs no work outside WASM.

Parameters

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

{}
F
function

TestProvideInject

Parameters

core/provide_inject_test.go:11-27
func TestProvideInject(t *testing.T)

{
	state.NewStore("default", state.WithModule("app"))

	parentTpl := []byte("<root></root>")
	childTpl := []byte("<root></root>")

	parent := NewComponent("Parent", parentTpl, nil)
	child := NewComponent("Child", childTpl, nil)

	parent.Provide("answer", 42)
	parent.AddDependency("child", child)

	v, ok := Inject[int](child, "answer")
	if !ok || v != 42 {
		t.Fatalf("expected injected 42, got %v", v)
	}
}
I
interface

Component

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

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

Methods

Render
Method

Returns

string
func Render(...)
GetName
Method

Returns

string
func GetName(...)
GetID
Method

Returns

string
func GetID(...)
F
function

RegisterComponent

RegisterComponent registers a component constructor for lookup by name. It
returns an error if a component with the same name has already been
registered and logs a warning.

Parameters

name
string
constructor
func() Component

Returns

error
core/component_host.go:27-36
func RegisterComponent(name string, constructor func() Component) error

{
	componentRegistryMu.Lock()
	defer componentRegistryMu.Unlock()
	if _, exists := ComponentRegistry[name]; exists {
		Log().Warn("component %s already registered", name)
		return fmt.Errorf("component %s already registered", name)
	}
	ComponentRegistry[name] = constructor
	return nil
}
F
function

LoadComponent

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

Parameters

name
string

Returns

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

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

MustRegisterComponent

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

Parameters

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

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

noopComponent

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

Methods

Render
Method

Returns

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

Returns

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

Returns

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

Parameters

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

Returns

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

Parameters

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

TestRegisterComponentDuplicate

Parameters

core/component_registry_test.go:20-29
func TestRegisterComponentDuplicate(t *testing.T)

{
	// reset registry
	ComponentRegistry = map[string]func() Component{}
	if err := RegisterComponent("dup", func() Component { return noopComponent{} }); err != nil {
		t.Fatalf("unexpected error registering component: %v", err)
	}
	if err := RegisterComponent("dup", func() Component { return noopComponent{} }); err == nil {
		t.Fatalf("expected error on duplicate registration")
	}
}
F
function

TestMustRegisterComponentPanic

Parameters

core/component_registry_test.go:31-40
func TestMustRegisterComponentPanic(t *testing.T)

{
	ComponentRegistry = map[string]func() Component{}
	MustRegisterComponent("dup", func() Component { return noopComponent{} })
	defer func() {
		if r := recover(); r == nil {
			t.Fatalf("expected panic on duplicate registration")
		}
	}()
	MustRegisterComponent("dup", func() Component { return noopComponent{} })
}
F
function

TestComponentDOMHookLifecycle

Parameters

core/dom_hook_test.go:11-49
func TestComponentDOMHookLifecycle(t *testing.T)

{
	if dom.ByID("app").IsNull() {
		host := dom.CreateElement("div")
		host.SetAttr("id", "app")
		dom.Doc().Body().AppendChild(host)
	}
	component := NewHTMLComponent("Hooked", []byte("<root><p>hooked</p></root>"), nil)
	component.SetComponent(component)
	component.Init(nil)

	mounted := 0
	updated := 0
	unmounted := 0
	cleaned := 0
	component.DOMHook(dom.LifecycleHook{
		Mounted: func(root dom.Element) func() {
			if root.Attr("data-component-id") != component.ID {
				t.Fatalf("hook received wrong root: %q", root.Attr("data-component-id"))
			}
			mounted++
			return func() { cleaned++ }
		},
		Updated: func(dom.Element) {
			updated++
		},
		Unmounted: func(dom.Element) {
			unmounted++
		},
	})

	dom.UpdateDOM(component.ID, component.Render())
	component.Mount()
	dom.UpdateMountedDOM(component.ID, component.RenderFresh())
	component.Unmount()

	if mounted != 1 || updated != 1 || unmounted != 1 || cleaned != 1 {
		t.Fatalf("unexpected hook counts: mount=%d update=%d unmount=%d cleanup=%d", mounted, updated, unmounted, cleaned)
	}
}
F
function

TestUnmountCleanupContinuesAfterLifecyclePanic

Parameters

core/dom_hook_test.go:51-72
func TestUnmountCleanupContinuesAfterLifecyclePanic(t *testing.T)

{
	if dom.ByID("app").IsNull() {
		host := dom.CreateElement("div")
		host.SetAttr("id", "app")
		dom.Doc().Body().AppendChild(host)
	}
	component := NewHTMLComponent("PanicCleanup", []byte("<root></root>"), nil)
	component.SetComponent(component)
	component.Init(nil)
	cleaned := false
	component.Scope().Defer(func() { cleaned = true })
	component.SetOnUnmount(func(*HTMLComponent) { panic("unmount") })
	stopErrors := OnError(func(any, string) {})
	defer stopErrors()

	dom.UpdateDOM(component.ID, component.Render())
	component.Mount()
	component.Unmount()
	if !cleaned {
		t.Fatal("scope cleanup stopped after lifecycle panic")
	}
}
F
function

TestForRendersComponentList

Parameters

core/for_component_test.go:12-27
func TestForRendersComponentList(t *testing.T)

{
	state.NewStore("default", state.WithModule("app"))

	childTpl1 := []byte("<root><p>first</p></root>")
	childTpl2 := []byte("<root><p>second</p></root>")
	child1 := NewComponent("Child1", childTpl1, nil)
	child2 := NewComponent("Child2", childTpl2, nil)

	parentTpl := []byte("<root>@for:item in items @prop:item @endfor</root>")
	parent := NewComponent("Parent", parentTpl, map[string]any{"items": []Component{child1, child2}})

	html := parent.Render()
	if !strings.Contains(html, "first") || !strings.Contains(html, "second") {
		t.Fatalf("expected child components rendered: %s", html)
	}
}
F
function

TestForRendersMapFields

Parameters

core/for_component_test.go:29-47
func TestForRendersMapFields(t *testing.T)

{
	state.NewStore("default", state.WithModule("app"))

	items := []any{
		map[string]any{"name": "Mario", "age": 30},
		map[string]any{"name": "Luigi", "age": 25},
	}

	parentTpl := []byte("<root>@for:item in items <p><b>Name:</b> @prop:item.name <b>Age:</b> @prop:item.age</p> @endfor</root>")
	parent := NewComponent("Parent", parentTpl, map[string]any{"items": items})

	html := parent.Render()
	if !strings.Contains(html, "Mario") || !strings.Contains(html, "Luigi") {
		t.Fatalf("expected names rendered: %s", html)
	}
	if strings.Contains(html, "@prop:item.name") || strings.Contains(html, "@prop:item.age") {
		t.Fatalf("placeholders not replaced: %s", html)
	}
}
S
struct

unsubscribes

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

Methods

Add
Method

Parameters

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

Fields

Name Type Description
funcs []func()
S
struct
Implements: Component

HTMLComponent

HTMLComponent renders RTML templates and manages their component state.

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

Methods

Stats
Method

Stats returns zeroed metrics on non-wasm builds.

Returns

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

Init attaches a state store and prepares the component template.

Parameters

store *state.Store
func (*HTMLComponent) Init(store *state.Store)
{
	if c.Store != nil {
		return
	}
	template, err := LoadComponentTemplate(c.TemplateFS)
	if err != nil {
		panic(fmt.Sprintf("Error loading template for component %s: %v", c.Name, err))
	}
	template = devOverrideTemplate(c, template)
	c.Template = template
	dom.RegisterBindings(c.ID, c.Name, template)
	devRegisterComponent(c)

	if store != nil {
		c.Store = store
	} else {
		c.Store = state.GlobalStoreManager.GetStore("app", "default")
		if c.Store == nil {
			c.Store = state.NewStore("default", state.WithModule("app"))
		}
	}
}
RenderFresh
Method

RenderFresh clears the render cache and re-renders. Reactive updates (store OnChange, signal effects) call this so a state change always produces up-to-date HTML instead of a stale cached render. Fixes the bug where a store.Set did not re-render @for / @expr / store-bound templates because the cache key hashes only Props/Dependencies, not the bound store state.

Returns

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

Invalidate drops the render cache of this component and of everything it includes. The cache key covers props and dependency identity, never the store state a template binds to, so an included component handed back its first render forever: a dependency whose markup depends on a shared store key (an @if on a global flag) froze at the value it had when the parent first painted.

func (*HTMLComponent) Invalidate()
{
	c.cache = nil
	c.lastCacheKey = ""
	for _, dep := range c.Dependencies {
		if d, ok := dep.(interface{ Invalidate() }); ok {
			d.Invalidate()
		}
	}
}
Render
Method

Render evaluates the component template.

Returns

renderedTemplate string
func (*HTMLComponent) Render() (renderedTemplate string)
{
	start := time.Now()
	defer func() { c.recordRender(time.Since(start)) }()
	key := c.cacheKey()
	if c.cache != nil {
		if val, ok := c.cache[key]; ok {
			renderedTemplate = val
			return
		}
		if c.lastCacheKey != "" && c.lastCacheKey != key {
			delete(c.cache, c.lastCacheKey)
		}
	} else {
		c.cache = make(map[string]string)
	}
	defer func() {
		if r := recover(); r != nil {
			ReportError(r, fmt.Sprintf("Render: %s (ID: %s)", c.Name, c.ID))
			renderedTemplate = ""
		}
	}()

	c.unsubscribes.Run()

	renderedTemplate = c.Template
	renderedTemplate = strings.Replace(renderedTemplate, "<root", fmt.Sprintf("<root data-component-id=\"%s\"", c.ID), 1)

	// Extract slot contents destined for child components
	renderedTemplate = extractSlotContents(renderedTemplate, c)

	// Replace this component's slot placeholders with provided content or fallbacks
	renderedTemplate = replaceSlotPlaceholders(renderedTemplate, c)

	// {{prop}} substitutions are HTML-escaped like @prop; @rawprop remains the
	// explicit escape hatch for trusted markup.
	for key, value := range c.Props {
		placeholder := fmt.Sprintf("{{%s}}", key)
		renderedTemplate = strings.ReplaceAll(renderedTemplate, placeholder, escapeValue(value))
	}

	// Register @include directives that supply inline props
	renderedTemplate = replaceComponentIncludes(renderedTemplate, c)

	// Handle @include:componentName syntax for dependencies
	renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)

	// Handle @for loops
	renderedTemplate = replaceForPlaceholders(renderedTemplate, c)

	renderedTemplate = replaceStorePlaceholders(renderedTemplate, c)
	renderedTemplate = replaceSignalPlaceholders(renderedTemplate, c)
	renderedTemplate = replaceExprInClassAttr(renderedTemplate, c)
	renderedTemplate = replaceExprPlaceholders(renderedTemplate, c)

	// Handle @prop:propName syntax for props
	renderedTemplate = replacePropPlaceholders(renderedTemplate, c)

	// Handle plugin variable and command placeholders
	renderedTemplate = replacePluginPlaceholders(renderedTemplate)

	// Handle host variable and command placeholders
	if len(c.hostComponentNames()) > 0 {
		renderedTemplate = replaceHostPlaceholders(renderedTemplate, c)
	}

	// Handle @if:condition syntax for conditional rendering
	renderedTemplate = replaceConditionals(renderedTemplate, c)

	// Handle @on:event:handler and @event:handler syntax for event binding
	renderedTemplate = replaceEventHandlers(renderedTemplate)

	// Handle rt-is="ComponentName" for dynamic component loading
	renderedTemplate = replaceRtIsAttributes(renderedTemplate, c)

	// Render any components introduced via rt-is placeholders
	renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)

	// Handle constructor decorators like [ref] and [key expr]
	renderedTemplate = replaceConstructors(renderedTemplate)

	for _, name := range c.hostComponentNames() {
		hostclient.RegisterComponent(c.ID, name, c.hostVars)
	}

	renderedTemplate = minifyInline(renderedTemplate)

	c.cache[key] = renderedTemplate
	c.lastCacheKey = key
	return renderedTemplate
}
recordRender
Method

Parameters

duration time.Duration
func (*HTMLComponent) recordRender(duration time.Duration)
{
	if c == nil {
		return
	}
	c.metricsMu.Lock()
	c.renderCount++
	c.totalRender += duration
	c.lastRender = duration
	c.appendTimelineLocked(ComponentTimelineEntry{
		Kind:      "render",
		Timestamp: time.Now(),
		Duration:  duration,
	})
	c.metricsMu.Unlock()
}

Parameters

func (*HTMLComponent) appendTimelineLocked(entry ComponentTimelineEntry)
{
	if entry.Kind == "" {
		return
	}
	if c.timeline == nil {
		c.timeline = make([]ComponentTimelineEntry, 0, 8)
	}
	c.timeline = append(c.timeline, entry)
	if len(c.timeline) > componentTimelineLimit {
		c.timeline = append([]ComponentTimelineEntry(nil), c.timeline[len(c.timeline)-componentTimelineLimit:]...)
	}
}
Stats
Method

Stats returns a snapshot of the component's render metrics.

Returns

func (*HTMLComponent) Stats() ComponentStats
{
	c.metricsMu.Lock()
	defer c.metricsMu.Unlock()
	stats := ComponentStats{
		RenderCount: c.renderCount,
		TotalRender: c.totalRender,
		LastRender:  c.lastRender,
	}
	if c.renderCount > 0 {
		stats.AverageRender = c.totalRender / time.Duration(c.renderCount)
	}
	if len(c.timeline) > 0 {
		stats.Timeline = append(stats.Timeline, c.timeline...)
	}
	return stats
}
AddDependency
Method

AddDependency attaches a child component to a template placeholder.

Parameters

placeholderName string
dep Component
func (*HTMLComponent) AddDependency(placeholderName string, dep Component)
{
	if c.Dependencies == nil {
		c.Dependencies = make(map[string]Component)
	}
	if depComp, ok := dep.(*HTMLComponent); ok {
		depComp.Init(c.Store)
		depComp.parent = c
	}
	c.Dependencies[placeholderName] = dep
}
Unmount
Method

Unmount releases component resources and child dependencies.

func (*HTMLComponent) Unmount()
{
	// The idempotence guard keeps finalizers from repeating lifecycle cleanup.
	if !c.mounted {
		return
	}
	c.mounted = false
	devUnregisterComponent(c)
	if c.component != nil {
		c.runLifecycle("OnUnmount", c.component.OnUnmount)
	}
	dom.UnmountLifecycleHooks(c.ID)
	c.releaseDOMHooks()
	if c.scope != nil {
		c.scope.Close()
	}

	dom.RemoveComponentSignals(c.ID)
	dom.ReleaseInputBindings(c.ID)
	dom.ReleaseComponentHandlers(c.ID)
	root := dom.ComponentRoot(c.ID)
	if !root.IsNull() && !root.IsUndefined() {
		dom.RemoveDelegatedEvents(c.ID, root.Value)
	}
	log.Printf("Unsubscribing %s from all stores", c.Name)
	c.unsubscribes.Run()

	for _, dep := range c.Dependencies {
		dependency := dep
		c.runLifecycle("dependency unmount", dependency.Unmount)
	}
}
Mount
Method

Mount activates the component and its child dependencies.

func (*HTMLComponent) Mount()
{
	c.mounted = true
	if c.scope == nil || c.scope.Closed() {
		c.scope = NewScope()
	}
	c.registerHandlers()
	c.registerDOMHooks()
	for _, dep := range c.Dependencies {
		dependency := dep
		c.runLifecycle("dependency mount", dependency.Mount)
	}
	root := dom.ComponentRoot(c.ID)
	if !root.IsNull() && !root.IsUndefined() {
		dom.DelegateEvents(c.ID, root.Value)
	}
	if c.component != nil {
		c.runLifecycle("OnMount", c.component.OnMount)
	}
	dom.MountLifecycleHooks(c.ID)
}
runLifecycle
Method

Parameters

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

Scope returns the lifecycle scope owned by this component.

Returns

func (*HTMLComponent) Scope() *Scope
{
	if c.scope == nil || c.scope.Closed() {
		c.scope = NewScope()
	}
	return c.scope
}
Effect
Method

Effect registers a reactive effect that stops on unmount.

Parameters

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

DOMHook registers root lifecycle callbacks owned by this component.

Parameters

func (*HTMLComponent) DOMHook(hook dom.LifecycleHook)
{
	c.domHooks = append(c.domHooks, hook)
	if c.mounted {
		c.domHookStops = append(c.domHookStops, dom.RegisterLifecycleHook(c.ID, hook))
		dom.MountLifecycleHooks(c.ID)
	}
}
func (*HTMLComponent) registerDOMHooks()
{
	c.releaseDOMHooks()
	for _, hook := range c.domHooks {
		c.domHookStops = append(c.domHookStops, dom.RegisterLifecycleHook(c.ID, hook))
	}
}
func (*HTMLComponent) releaseDOMHooks()
{
	for _, stop := range c.domHookStops {
		stop()
	}
	c.domHookStops = nil
}
On
Method

On registers an event handler owned by this component instance.

Parameters

name string
fn func()
func (*HTMLComponent) On(name string, fn func())
{
	if name == "" {
		panic("core.HTMLComponent.On: empty handler name")
	}
	if fn == nil {
		panic("core.HTMLComponent.On: nil fn")
	}
	c.handlers[name] = fn
	dom.RegisterComponentHandlerFunc(c.ID, name, fn)
}
func (*HTMLComponent) registerHandlers()
{
	for name, fn := range c.handlers {
		dom.RegisterComponentHandlerFunc(c.ID, name, fn)
	}
}
GetName
Method

GetName returns the component name.

Returns

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

GetID returns the component identifier.

Returns

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

GetRef returns the DOM element annotated with a matching constructor decorator. It searches within this component's root element using the data-ref attribute injected during template rendering.

Parameters

name string

Returns

func (*HTMLComponent) GetRef(name string) dom.Element
{
	root := dom.ComponentRoot(c.ID)
	if root.IsNull() || root.IsUndefined() {
		return dom.Element{}
	}
	return root.Query(fmt.Sprintf(`[data-ref="%s"]`, name))
}
OnMount
Method

OnMount runs the configured mount callback.

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

OnUnmount runs the configured unmount callback.

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

IsMounted reports whether the component is mounted.

Returns

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

OnParams runs the configured route-parameter callback.

Parameters

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

SetOnParams configures the route-parameter callback.

Parameters

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

SetOnMount configures the mount callback.

Parameters

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

SetOnUnmount configures the unmount callback.

Parameters

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

WithLifecycle configures mount and unmount callbacks.

Parameters

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

Returns

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

SetComponent attaches the component lifecycle implementation.

Parameters

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

SetSlots merges named slot content into the component.

Parameters

slots map[string]any
func (*HTMLComponent) SetSlots(slots map[string]any)
{
	if c.Slots == nil {
		c.Slots = make(map[string]any)
	}
	for k, v := range slots {
		c.Slots[k] = v
	}
}
Provide
Method

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

Parameters

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

Inject searches for a provided value starting from this component and walking up the parent chain. It returns the value as `any` and whether it was found. Callers can type-assert the result.

Parameters

key string

Returns

any
bool
func (*HTMLComponent) Inject(key string) (any, bool)
{
	if c.provides != nil {
		if v, ok := c.provides[key]; ok {
			return v, true
		}
	}
	if c.parent != nil {
		return c.parent.Inject(key)
	}
	return nil, false
}

SetRouteParams merges route parameters into component props.

Parameters

params map[string]string
func (*HTMLComponent) SetRouteParams(params map[string]string)
{
	if c.Props == nil {
		c.Props = make(map[string]any)
	}
	for k, v := range params {
		c.Props[k] = v
	}
}

AddHostComponent links this HTML component to a server-side HostComponent by name. When running in SSC mode, messages from the wasm runtime will be routed to the corresponding host component on the server. It may be called multiple times (e.g. a composition struct with several host fields): every name is registered, and HostComponent keeps the first one as the primary.

Parameters

name string
func (*HTMLComponent) AddHostComponent(name string)
{
	for _, n := range c.hostComponents {
		if n == name {
			return
		}
	}
	c.hostComponents = append(c.hostComponents, name)
	if c.HostComponent == "" {
		c.HostComponent = name
	}
}

hostComponentNames returns every host component linked to this component, including a HostComponent assigned directly to the exported field.

Returns

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

Returns

string
func (*HTMLComponent) cacheKey() string
{
	hasher := sha256.New()
	hasher.Write([]byte(serializeProps(c.Props)))

	if len(c.Dependencies) > 0 {
		deps := make([]string, 0, len(c.Dependencies))
		for name, dep := range c.Dependencies {
			deps = append(deps, name+dep.GetID())
		}
		sort.Strings(deps)
		for _, d := range deps {
			hasher.Write([]byte(d))
		}
	}

	return hex.EncodeToString(hasher.Sum(nil)[:20])
}
Render
Method

Render returns no markup outside WASM.

Returns

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

Mount performs no work outside WASM.

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

Unmount performs no work outside WASM.

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

OnMount performs no work outside WASM.

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

OnUnmount performs no work outside WASM.

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

GetName returns the component name.

Returns

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

GetID returns the component ID.

Returns

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

SetSlots performs no work outside WASM.

Parameters

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

Scope returns the component lifecycle scope.

Returns

func (*HTMLComponent) Scope() *Scope
{
	if c.scope == nil || c.scope.Closed() {
		c.scope = NewScope()
	}
	return c.scope
}

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

fragment string

Returns

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

ComponentStats

ComponentStats contains aggregated render metrics for an HTML component.

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

Fields

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

ComponentTimelineEntry

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

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

Fields

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

NewHTMLComponent

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

Parameters

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

Returns

core/html_component.go:104-122
func NewHTMLComponent(name string, templateFs []byte, props map[string]any) *HTMLComponent

{
	id := generateComponentID(name, props)
	c := &HTMLComponent{
		ID:                id,
		Name:              name,
		TemplateFS:        templateFs,
		Dependencies:      make(map[string]Component),
		Props:             props,
		Slots:             make(map[string]any),
		handlers:          make(map[string]func()),
		scope:             NewScope(),
		conditionContents: make(map[string]ConditionContent),
		exprContents:      make(map[string]string),
		classExprContents: make(map[string]string),
	}
	// Attempt automatic cleanup when component is garbage collected.
	runtime.SetFinalizer(c, func(hc *HTMLComponent) { hc.Unmount() })
	return c
}
F
function

minifyInline

Parameters

src
string

Returns

string
core/html_component.go:321-340
func minifyInline(src string) string

{
	inlineMinifierOnce.Do(func() {
		inlineMinifier = minify.New()
		inlineMinifier.AddFunc("text/javascript", tdJs.Minify)
		inlineMinifier.AddFunc("text/css", css.Minify)
	})
	return inlineRe.ReplaceAllStringFunc(src, func(match string) string {
		m := inlineRe.FindStringSubmatch(match)
		tag, attrs, code := m[1], m[2], m[3]
		media := "text/javascript"
		if tag == "style" {
			media = "text/css"
		}
		out, err := inlineMinifier.String(media, code)
		if err != nil {
			return match
		}
		return fmt.Sprintf("<%s%s>%s</%s>", tag, attrs, strings.TrimSpace(out), tag)
	})
}
F
function

Inject

Inject performs a typed lookup of a provided component value.

Parameters

key
string

Returns

T
bool
core/html_component.go:582-590
func Inject[T any](c *HTMLComponent, key string) (T, bool)

{
	v, ok := c.Inject(key)
	if !ok {
		var zero T
		return zero, false
	}
	t, ok := v.(T)
	return t, ok
}
F
function

generateComponentID

Parameters

name
string
props
map[string]any

Returns

string
core/html_component.go:649-657
func generateComponentID(name string, props map[string]any) string

{
	hasher := sha256.New()
	hasher.Write([]byte(name))
	propsString := serializeProps(props)
	hasher.Write([]byte(propsString))
	hasher.Write([]byte(strconv.FormatUint(componentSeq.Add(1), 10)))

	return hex.EncodeToString(hasher.Sum(nil)[:20])
}
F
function

serializeProps

Parameters

props
map[string]any

Returns

string
core/html_component.go:659-676
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()
}
S
struct
Implements: Component

Portal

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

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

Methods

Render
Method

Render returns the portal anchor markup.

Returns

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

Mount renders the child into the portal target.

func (*Portal) Mount()
{
	if portal.mounted || portal.child == nil {
		return
	}
	target := dom.Query(portal.selector)
	if target.IsNull() || target.IsUndefined() {
		return
	}
	container := dom.CreateElement("div")
	container.SetAttr("data-portal-id", portal.id)
	target.AppendChild(container)
	portal.container = container
	dom.UpdateDOMIn(container, portal.child.GetID(), TryRender(portal.child))
	portal.child.Mount()
	portal.mounted = true
}
Unmount
Method

Unmount removes the portal child and container.

func (*Portal) Unmount()
{
	if !portal.mounted {
		return
	}
	portal.child.Unmount()
	if !portal.container.IsNull() && !portal.container.IsUndefined() {
		portal.container.Call("remove")
	}
	portal.container = dom.Element{}
	portal.mounted = false
}
OnMount
Method

OnMount performs no additional work.

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

OnUnmount performs no additional work.

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

GetName returns the portal component name.

Returns

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

GetID returns the portal component ID.

Returns

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

SetSlots forwards slots to the child component.

Parameters

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

IsMounted reports whether the portal is mounted.

Returns

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

OnParams forwards route parameters to the child.

Parameters

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

Fields

Name Type Description
id string
selector string
child Component
container dom.Element
mounted bool
F
function

NewPortal

NewPortal creates a portal targeting a CSS selector.

Parameters

selector
string
child

Returns

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

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

KeepAliveAware

KeepAliveAware receives activation events without being unmounted.

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

Methods

OnActivate
Method
func OnActivate(...)
OnDeactivate
Method
func OnDeactivate(...)
S
struct
Implements: Component

KeepAlive

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

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

Methods

Render
Method

Render returns the wrapper markup for the cached child.

Returns

string
func (*KeepAlive) Render() string
{
	content := ""
	if !keep.cached && !keep.disposed && keep.child != nil {
		content = TryRender(keep.child)
	}
	return `<root data-component-id="` + keep.id + `"><div data-keepalive-host>` + content + `</div></root>`
}
Mount
Method

Mount restores or initializes the cached child.

func (*KeepAlive) Mount()
{
	if keep.mounted || keep.disposed || keep.child == nil {
		return
	}
	if keep.cached && keep.fragment.Truthy() {
		root := dom.ComponentRoot(keep.id)
		host := root.Query("[data-keepalive-host]")
		if !host.IsNull() && !host.IsUndefined() {
			host.Call("appendChild", keep.fragment)
		}
		keep.cached = false
	}
	if !keep.initialized {
		keep.child.Mount()
		keep.initialized = true
	} else if aware, ok := keep.child.(KeepAliveAware); ok {
		aware.OnActivate()
	}
	keep.mounted = true
}
Unmount
Method

Unmount detaches the child while retaining its state.

func (*KeepAlive) Unmount()
{
	if !keep.mounted || keep.disposed || keep.child == nil {
		return
	}
	root := dom.ComponentRoot(keep.id)
	if root.Attr("data-component-id") == keep.id {
		childRoot := root.Query(`[data-component-id="` + keep.child.GetID() + `"]`)
		if !childRoot.IsNull() && !childRoot.IsUndefined() {
			fragment := js.Document().Call("createDocumentFragment")
			fragment.Call("appendChild", childRoot.Value)
			keep.fragment = fragment
			keep.cached = true
		}
	}
	if aware, ok := keep.child.(KeepAliveAware); ok {
		aware.OnDeactivate()
	}
	keep.mounted = false
}
Dispose
Method

Dispose permanently unmounts the cached child.

func (*KeepAlive) Dispose()
{
	if keep.disposed {
		return
	}
	root := dom.ComponentRoot(keep.id)
	if keep.cached && keep.fragment.Truthy() {
		holder := dom.CreateElement("div")
		holder.SetStyle("display", "none")
		dom.Doc().Body().AppendChild(holder)
		holder.Call("appendChild", keep.fragment)
		keep.child.Unmount()
		holder.Call("remove")
	} else if keep.initialized {
		keep.child.Unmount()
	}
	keep.fragment = js.Undefined()
	keep.cached = false
	keep.mounted = false
	keep.disposed = true
	if root.Attr("data-component-id") == keep.id {
		root.Call("remove")
	}
}
OnMount
Method

OnMount handles the component lifecycle callback.

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

OnUnmount handles the component lifecycle callback.

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

GetName returns the component name.

Returns

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

GetID returns the component identifier.

Returns

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

SetSlots forwards slots to the cached child.

Parameters

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

IsMounted reports whether the component is mounted.

Returns

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

OnParams forwards route parameters to the cached child.

Parameters

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

Fields

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

NewKeepAlive

NewKeepAlive creates a state-preserving component wrapper.

Parameters

child

Returns

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

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

TransitionConfig

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

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

Fields

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

DefaultTransitionConfig

DefaultTransitionConfig returns class names compatible with plain CSS.

core/builtin_components.go:241-251
func DefaultTransitionConfig() TransitionConfig

{
	return TransitionConfig{
		EnterFrom:   "rfw-enter-from",
		EnterActive: "rfw-enter-active",
		EnterTo:     "rfw-enter-to",
		LeaveFrom:   "rfw-leave-from",
		LeaveActive: "rfw-leave-active",
		LeaveTo:     "rfw-leave-to",
		Duration:    200 * time.Millisecond,
	}
}
S
struct
Implements: Component

Transition

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

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

Methods

Render
Method

Render returns the transition wrapper markup.

Returns

string
func (*Transition) Render() string
{
	content := ""
	if transition.child != nil {
		content = TryRender(transition.child)
	}
	return `<root data-component-id="` + transition.id + `" data-transition>` + content + `</root>`
}
Mount
Method

Mount inserts the child and applies enter classes.

func (*Transition) Mount()
{
	if transition.mounted || transition.child == nil {
		return
	}
	if transition.timer != nil {
		transition.timer.Stop()
		transition.timer = nil
	}
	if transition.leaving.Attr("data-component-id") == transition.id {
		transition.child.Unmount()
		transition.leaving.Call("remove")
		transition.leaving = dom.Element{}
	}
	transition.mounted = true
	transition.child.Mount()
	root := dom.ComponentRoot(transition.id)
	addClasses(root, transition.config.EnterFrom, transition.config.EnterActive)
	js.OnAnimationFrame(func() {
		if !transition.mounted {
			return
		}
		removeClasses(root, transition.config.EnterFrom)
		addClasses(root, transition.config.EnterTo)
	})
	transition.timer = time.AfterFunc(transition.config.Duration, func() {
		if transition.mounted {
			removeClasses(root, transition.config.EnterActive, transition.config.EnterTo)
		}
	})
}
Unmount
Method

Unmount applies leave classes before removing the child.

func (*Transition) Unmount()
{
	if !transition.mounted || transition.child == nil {
		return
	}
	transition.mounted = false
	if transition.timer != nil {
		transition.timer.Stop()
	}
	root := dom.ComponentRoot(transition.id)
	if root.Attr("data-component-id") != transition.id {
		transition.child.Unmount()
		return
	}
	dom.Doc().Body().AppendChild(root)
	transition.leaving = root
	removeClasses(root, transition.config.EnterFrom, transition.config.EnterActive, transition.config.EnterTo)
	addClasses(root, transition.config.LeaveFrom, transition.config.LeaveActive)
	js.OnAnimationFrame(func() {
		removeClasses(root, transition.config.LeaveFrom)
		addClasses(root, transition.config.LeaveTo)
	})
	transition.timer = time.AfterFunc(transition.config.Duration, func() {
		transition.child.Unmount()
		root.Call("remove")
		removeClasses(root, transition.config.LeaveActive, transition.config.LeaveTo)
		transition.leaving = dom.Element{}
		transition.timer = nil
	})
}
Dispose
Method

Dispose removes a transition immediately.

func (*Transition) Dispose()
{
	if transition.timer != nil {
		transition.timer.Stop()
		transition.timer = nil
	}
	if transition.child != nil && transition.child.IsMounted() {
		transition.child.Unmount()
	}
	root := dom.ComponentRoot(transition.id)
	if root.Attr("data-component-id") == transition.id {
		root.Call("remove")
	}
	transition.leaving = dom.Element{}
	transition.mounted = false
}
OnMount
Method

OnMount handles the component lifecycle callback.

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

OnUnmount handles the component lifecycle callback.

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

GetName returns the component name.

Returns

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

GetID returns the component identifier.

Returns

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

SetSlots forwards slots to the child.

Parameters

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

IsMounted reports whether the component is mounted.

Returns

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

OnParams forwards route parameters to the child.

Parameters

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

Fields

Name Type Description
id string
child Component
config TransitionConfig
mounted bool
timer *time.Timer
leaving dom.Element
F
function

NewTransition

NewTransition creates a CSS transition wrapper.

Parameters

Returns

core/builtin_components.go:264-292
func NewTransition(child Component, config TransitionConfig) *Transition

{
	defaults := DefaultTransitionConfig()
	if config.EnterFrom == "" {
		config.EnterFrom = defaults.EnterFrom
	}
	if config.EnterActive == "" {
		config.EnterActive = defaults.EnterActive
	}
	if config.EnterTo == "" {
		config.EnterTo = defaults.EnterTo
	}
	if config.LeaveFrom == "" {
		config.LeaveFrom = defaults.LeaveFrom
	}
	if config.LeaveActive == "" {
		config.LeaveActive = defaults.LeaveActive
	}
	if config.LeaveTo == "" {
		config.LeaveTo = defaults.LeaveTo
	}
	if config.Duration == 0 {
		config.Duration = defaults.Duration
	}
	return &Transition{
		id:     generateComponentID("Transition", nil),
		child:  child,
		config: config,
	}
}
F
function

addClasses

Parameters

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

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

removeClasses

Parameters

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

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

ensureAppRoot

Returns

core/builtin_components_test.go:13-22
func ensureAppRoot() dom.Element

{
	app := dom.ByID("app")
	if app.IsNull() {
		app = dom.CreateElement("div")
		app.SetAttr("id", "app")
		dom.Doc().Body().AppendChild(app)
	}
	app.SetHTML("")
	return app
}
F
function

testHTMLComponent

Parameters

name
string
template
string

Returns

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

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

TestPortalMountsOutsideComponentTree

Parameters

core/builtin_components_test.go:31-53
func TestPortalMountsOutsideComponentTree(t *testing.T)

{
	ensureAppRoot()
	target := dom.CreateElement("div")
	target.SetAttr("id", "portal-test-target")
	dom.Doc().Body().AppendChild(target)
	defer target.Call("remove")

	child := testHTMLComponent("PortalChild", `<root><p id="portal-content">content</p></root>`)
	portal := NewPortal("#portal-test-target", child)
	dom.UpdateDOM(portal.GetID(), portal.Render())
	portal.Mount()

	if target.Query("#portal-content").IsNull() {
		t.Fatal("portal child did not mount in target")
	}
	if !child.IsMounted() {
		t.Fatal("portal child was not mounted")
	}
	portal.Unmount()
	if !target.Query("#portal-content").IsNull() || child.IsMounted() {
		t.Fatal("portal child was not cleaned up")
	}
}
F
function

TestKeepAlivePreservesDOMAndState

Parameters

core/builtin_components_test.go:55-80
func TestKeepAlivePreservesDOMAndState(t *testing.T)

{
	app := ensureAppRoot()
	child := testHTMLComponent("CachedChild", `<root><input id="cached-input" value="initial"></root>`)
	keep := NewKeepAlive(child)
	dom.UpdateDOM(keep.GetID(), keep.Render())
	keep.Mount()

	input := dom.ByID("cached-input")
	input.SetValue("edited")
	keep.Unmount()
	app.SetHTML("<p>other route</p>")

	dom.UpdateDOM(keep.GetID(), keep.Render())
	keep.Mount()
	if value := dom.ByID("cached-input").Val(); value != "edited" {
		t.Fatalf("cached DOM state was lost: %q", value)
	}
	if !child.IsMounted() {
		t.Fatal("cached child was unmounted")
	}

	keep.Dispose()
	if child.IsMounted() || !dom.ByID("cached-input").IsNull() {
		t.Fatal("disposed cache kept the child alive")
	}
}
F
function

TestTransitionRunsEnterAndLeavePhases

Parameters

core/builtin_components_test.go:82-109
func TestTransitionRunsEnterAndLeavePhases(t *testing.T)

{
	ensureAppRoot()
	child := testHTMLComponent("TransitionChild", `<root><p>transition</p></root>`)
	transition := NewTransition(child, TransitionConfig{Duration: 20 * time.Millisecond})
	dom.UpdateDOM(transition.GetID(), transition.Render())
	transition.Mount()

	root := dom.ComponentRoot(transition.GetID())
	if !root.HasClass("rfw-enter-from") || !root.HasClass("rfw-enter-active") {
		t.Fatalf("enter phase classes missing: %s", root.Attr("class"))
	}
	transition.Unmount()
	if !root.HasClass("rfw-leave-from") || !root.HasClass("rfw-leave-active") {
		t.Fatalf("leave phase classes missing: %s", root.Attr("class"))
	}

	deadline := time.Now().Add(time.Second)
	for {
		html := dom.Doc().Body().HTML()
		if !child.IsMounted() && !strings.Contains(html, `data-component-id="`+transition.GetID()+`"`) {
			break
		}
		if time.Now().After(deadline) {
			t.Fatalf("transition leave did not finish: mounted=%v html=%s", child.IsMounted(), html)
		}
		time.Sleep(time.Millisecond)
	}
}
F
function

TestTransitionCanRemountDuringLeave

Parameters

core/builtin_components_test.go:111-128
func TestTransitionCanRemountDuringLeave(t *testing.T)

{
	app := ensureAppRoot()
	child := testHTMLComponent("TransitionReturnChild", `<root><p id="transition-return">return</p></root>`)
	transition := NewTransition(child, TransitionConfig{Duration: time.Second})
	dom.UpdateDOM(transition.GetID(), transition.Render())
	transition.Mount()
	transition.Unmount()

	app.SetHTML(transition.Render())
	transition.Mount()
	if !child.IsMounted() || dom.ByID("transition-return").IsNull() {
		t.Fatal("transition did not remount during leave")
	}
	if roots := dom.QueryAll(`[data-component-id="` + transition.GetID() + `"]`); roots.Length() != 1 {
		t.Fatalf("transition left %d roots after remount", roots.Length())
	}
	transition.Dispose()
}
F
function

TestMountedDependencyConditionUpdatesDOM

The shell case: a mounted parent re-renders because one of its own store
lists changed, and an included dependency gates its markup on another key of
the same store. The dependency has to follow the store in the DOM, not just
in a fresh render.

Parameters

core/dependency_condition_dom_test.go:17-65
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)
	}
}
I
interface

Logger

Logger defines logging interface used by the framework.

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

Methods

Debug
Method

Parameters

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

Parameters

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

Parameters

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

Parameters

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

init

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

{ state.SetLogger(logger) }
F
function

SetLogger

SetLogger allows applications to replace the default logger.

Parameters

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

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

Log

Log returns the active logger implementation.

Returns

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

{ return logger }
S
struct
Implements: Logger

defaultLogger

defaultLogger is the fallback logger using the standard log package.

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

Methods

Debug
Method

Parameters

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

Parameters

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

Parameters

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

Parameters

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

Plugin

Plugin defines interface for plugins to register hooks on the App. Plugins can
provide a build step which is executed by the CLI before the application is
run and may also attach runtime hooks through Install.

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

Methods

Build
Method

Parameters

Returns

error
func Build(...)
Install
Method

Parameters

*App
func Install(...)
I
interface

Named

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

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

Methods

Name
Method

Returns

string
func Name(...)
I
interface

Requires

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

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

Methods

Requires
Method

Returns

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

Optional

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

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

Methods

Optional
Method

Returns

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

PreBuilder

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

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

Methods

PreBuild
Method

Parameters

Returns

error
func PreBuild(...)
I
interface

PostBuilder

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

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

Methods

PostBuild
Method

Parameters

Returns

error
func PostBuild(...)
I
interface

Uninstaller

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

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

Methods

Uninstall
Method

Parameters

*App
func Uninstall(...)
I
interface

Provider

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

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

Methods

Provide
Method

Returns

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

App

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

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

Methods

RegisterRouter performs no work outside WASM.

Parameters

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

RegisterStore performs no work outside WASM.

Parameters

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

RegisterLifecycle performs no work outside WASM.

Parameters

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

RegisterTemplate performs no work outside WASM.

Parameters

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

RegisterRTMLVar performs no work outside WASM.

Parameters

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

HasPlugin reports false outside WASM.

Parameters

string

Returns

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

RegisterRouter adds a router navigation hook.

Parameters

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

RegisterStore adds a store mutation hook.

Parameters

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

RegisterTemplate adds a template render hook.

Parameters

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

RegisterLifecycle adds hooks for component mount and unmount.

Parameters

mount func(Component)
unmount func(Component)
func (*App) RegisterLifecycle(mount, unmount func(Component))
{
	if mount != nil {
		a.mountHooks = append(a.mountHooks, mount)
	}
	if unmount != nil {
		a.unmountHooks = append(a.unmountHooks, unmount)
	}
}

RegisterRTMLVar registers a value that can be referenced from RTML as {plugin:NAME.VAR}.

Parameters

plugin string
name string
val any
func (*App) RegisterRTMLVar(plugin, name string, val any)
{
	if a.pluginVars == nil {
		a.pluginVars = make(map[string]map[string]any)
	}
	if _, ok := a.pluginVars[plugin]; !ok {
		a.pluginVars[plugin] = make(map[string]any)
	}
	a.pluginVars[plugin][name] = val
}
HasPlugin
Method

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

Parameters

name string

Returns

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

Fields

Name Type Description
pluginVars map[string]map[string]any
plugins map[string]Plugin
provides map[string]any
S
struct

hooks

core/plugin.go:65-71
type hooks struct

Fields

Name Type Description
routerHooks []func(string)
storeHooks []func(module, store, key string, value any)
templateHooks []func(componentID, html string)
mountHooks []func(Component)
unmountHooks []func(Component)
F
function

newApp

newApp creates an App with initialized hook storage.

Returns

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

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

getRTMLVar

getRTMLVar retrieves a registered plugin variable.

Parameters

plugin
string
name
string

Returns

any
bool
core/plugin.go:116-125
func getRTMLVar(plugin, name string) (any, bool)

{
	if app.pluginVars == nil {
		return nil, false
	}
	if vars, ok := app.pluginVars[plugin]; ok {
		v, ok := vars[name]
		return v, ok
	}
	return nil, false
}
F
function

RegisterPluginVar

RegisterPluginVar is a convenience wrapper for plugins to expose variables.

Parameters

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

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

RegisterPlugin

RegisterPlugin registers a plugin and allows it to add hooks. If the plugin
implements Named and has already been installed, it is skipped.

Parameters

p
core/plugin.go:145-181
func RegisterPlugin(p Plugin)

{
	if n, ok := p.(Named); ok {
		if app.HasPlugin(n.Name()) {
			return
		}
		if app.plugins == nil {
			app.plugins = make(map[string]Plugin)
		}
		app.plugins[n.Name()] = p
	}
	if r, ok := p.(Requires); ok {
		for _, dep := range r.Requires() {
			if dn, ok := dep.(Named); ok {
				if app.HasPlugin(dn.Name()) {
					continue
				}
			}
			RegisterPlugin(dep)
		}
	}
	if o, ok := p.(Optional); ok {
		for _, dep := range o.Optional() {
			if dn, ok := dep.(Named); ok {
				if app.HasPlugin(dn.Name()) {
					continue
				}
			}
			RegisterPlugin(dep)
		}
	}
	p.Install(app)
	if prov, ok := p.(Provider); ok {
		for k, v := range prov.Provide() {
			app.provides[k] = v
		}
	}
}
F
function

GetProvider

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

Parameters

key
string

Returns

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

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

TriggerRouter

TriggerRouter invokes router hooks with the given path.

Parameters

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

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

OnNavigate

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

Parameters

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

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

OnTemplate

OnTemplate registers a function called with the rendered HTML every time a
component paints, the hook for work that must follow a render it does not
own (the router outlet repainting itself inside a re-rendered shell).

Parameters

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

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

TriggerStore

TriggerStore invokes store hooks for a mutation.

Parameters

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

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

TriggerTemplate

TriggerTemplate invokes template hooks with rendered HTML for a component.

Parameters

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

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

TriggerMount

TriggerMount invokes mount lifecycle hooks.

Parameters

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

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

TriggerUnmount

TriggerUnmount invokes unmount lifecycle hooks.

Parameters

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

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

init

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

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

TestDuplicateConditionsKeepTheirOwnContent

Two @if blocks carrying the same condition are two independent blocks. They
used to hash to the same id, so they shared one content entry and the patch
wrote the second block’s markup into the first one.

Parameters

core/rtml_condition_duplicate_test.go:15-56
func TestDuplicateConditionsKeepTheirOwnContent(t *testing.T)

{
	store := state.NewStore("dupcond", state.WithModule("app"))
	store.Set("chrome", "on")

	tpl := []byte(`<root>
@if:store:app.dupcond.chrome == "on"
<aside data-first>first</aside>
@endif
<main>body</main>
@if:store:app.dupcond.chrome == "on"
<header data-second>second</header>
@endif
</root>`)

	c := NewHTMLComponent("DupCond", tpl, nil)
	c.SetComponent(c)
	c.Init(nil)

	html := c.Render()
	if !strings.Contains(html, "data-first") || !strings.Contains(html, "data-second") {
		t.Fatalf("both blocks should render, got: %s", html)
	}

	ids := map[string]int{}
	for _, part := range strings.Split(html, `data-condition="`)[1:] {
		ids[part[:strings.Index(part, `"`)]]++
	}
	if len(ids) != 2 {
		t.Fatalf("expected two distinct condition ids, got %v", ids)
	}
	for id, n := range ids {
		if n != 1 {
			t.Fatalf("condition id %s used %d times", id, n)
		}
	}

	first := strings.Index(html, "data-first")
	second := strings.Index(html, "data-second")
	if first > second {
		t.Fatalf("blocks rendered out of order: %s", html)
	}
}
F
function

TestDuplicateConditionIDsAreStableAcrossRenders

Re-rendering has to hand every block the same id it had before, or a patch
after a store change lands on the wrong node.

Parameters

core/rtml_condition_duplicate_test.go:60-96
func TestDuplicateConditionIDsAreStableAcrossRenders(t *testing.T)

{
	store := state.NewStore("dupcond2", state.WithModule("app"))
	store.Set("chrome", "on")

	tpl := []byte(`<root>
@if:store:app.dupcond2.chrome == "on"
<aside>a</aside>
@endif
@if:store:app.dupcond2.chrome == "on"
<header>b</header>
@endif
</root>`)

	c := NewHTMLComponent("DupCond2", tpl, nil)
	c.SetComponent(c)
	c.Init(nil)

	ids := func() []string {
		html := c.RenderFresh()
		var out []string
		for _, part := range strings.Split(html, `data-condition="`)[1:] {
			out = append(out, part[:strings.Index(part, `"`)])
		}
		return out
	}

	before := ids()
	after := ids()
	if len(before) != 2 || len(after) != 2 {
		t.Fatalf("expected two blocks per render, got %v and %v", before, after)
	}
	for i := range before {
		if before[i] != after[i] {
			t.Fatalf("condition id %d changed between renders: %s -> %s", i, before[i], after[i])
		}
	}
}
F
function

reportScopeError

Parameters

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

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

Version

Version returns the framework version for this build.

Returns

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

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

TestComponentRegistryConcurrentAccess

Parameters

core/component_registry_concurrent_test.go:9-30
func TestComponentRegistryConcurrentAccess(t *testing.T)

{
	componentRegistryMu.Lock()
	ComponentRegistry = map[string]func() Component{}
	componentRegistryMu.Unlock()

	const n = 100
	var wg sync.WaitGroup
	wg.Add(n)
	for i := 0; i < n; i++ {
		go func(i int) {
			defer wg.Done()
			name := fmt.Sprintf("comp-%d", i)
			if err := RegisterComponent(name, func() Component { return noopComponent{} }); err != nil {
				t.Errorf("register %s: %v", name, err)
			}
			if c := LoadComponent(name); c == nil {
				t.Errorf("load %s: got nil", name)
			}
		}(i)
	}
	wg.Wait()
}
F
function

devOverrideTemplate

Parameters

template
string

Returns

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

{ return template }
F
function

devRegisterComponent

Parameters

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

{}
F
function

devUnregisterComponent

Parameters

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

{}
F
function

TestConditionOnStoreReactsWithoutOtherBindings

A component whose only reference to a store is an @if condition still has to
react to that key: without a subscription it renders once and freezes.

Parameters

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

resolveNestedKey

Parameters

m
map[string]any
key
string

Returns

any
bool
core/rtml_for.go:15-29
func resolveNestedKey(m map[string]any, key string) (any, bool)

{
	parts := strings.Split(key, ".")
	val := any(m)
	for _, part := range parts {
		sub, ok := val.(map[string]any)
		if !ok {
			return nil, false
		}
		val, ok = sub[part]
		if !ok {
			return nil, false
		}
	}
	return val, true
}
F
function

replaceForPlaceholders

Parameters

template
string

Returns

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

forAnchor

forAnchor marks where a loop’s rows begin. A template element carries no box
and no layout, so it sits inside a flex or grid container without disturbing
it, and an empty list still leaves the patch somewhere to insert into.

Parameters

loopID
string

Returns

string
core/rtml_for.go:144-146
func forAnchor(loopID string) string

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

insertRowMarkers

insertRowMarkers stamps the row key and the loop id on the row’s opening tag,
so a patch finds exactly the nodes the loop owns without touching the markup
inside them.

Parameters

content
string
key
any
loopID
string

Returns

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

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

body
string

Returns

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

expandForRows

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

Parameters

aliases
[]string
loopContent
string
collection
any
loopID
string

Returns

string
bool
core/rtml_for.go:198-256
func expandForRows(c *HTMLComponent, aliases []string, loopContent string, collection any, loopID string) (string, bool)

{
	switch col := collection.(type) {
	case nil:
		// unset store key: no rows, and the anchor keeps the spot so the first
		// value that lands can be patched in
		return "", true
	case []any:
		var result strings.Builder
		alias := aliases[0]
		for idx, item := range col {
			iterContent := loopContent
			if comp, ok := item.(Component); ok {
				placeholder := fmt.Sprintf("for-%s-%d", alias, idx)
				c.AddDependency(placeholder, comp)
				iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", alias), fmt.Sprintf("@include:%s", placeholder))
			} else if itemMap, ok := item.(map[string]any); ok {
				iterContent = substituteItemFields(iterContent, alias, itemMap)
			} else {
				iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", alias), escapeValue(item))
			}
			iterContent = insertRowMarkers(iterContent, idx, loopID)
			result.WriteString(iterContent)
		}
		return result.String(), true
	case map[string]any:
		keyAlias := aliases[0]
		valAlias := keyAlias
		if len(aliases) > 1 {
			valAlias = aliases[1]
		}
		keys := make([]string, 0, len(col))
		for k := range col {
			keys = append(keys, k)
		}
		sort.Strings(keys)
		var result strings.Builder
		for idx, k := range keys {
			v := col[k]
			iterContent := strings.ReplaceAll(loopContent, fmt.Sprintf("@prop:%s", keyAlias), escapeValue(k))
			if len(aliases) > 1 {
				if vMap, ok := v.(map[string]any); ok {
					iterContent = substituteItemFields(iterContent, valAlias, vMap)
				} else if comp, ok := v.(Component); ok {
					placeholder := fmt.Sprintf("for-%s-%d", valAlias, idx)
					c.AddDependency(placeholder, comp)
					iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", valAlias), fmt.Sprintf("@include:%s", placeholder))
				} else {
					iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@rawprop:%s", valAlias), fmt.Sprintf("%v", v))
					iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", valAlias), escapeValue(v))
				}
			}
			iterContent = insertRowMarkers(iterContent, k, loopID)
			result.WriteString(iterContent)
		}
		return result.String(), true
	default:
		return "", false
	}
}
F
function

substituteItemFields

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

Parameters

content
string
alias
string
item
map[string]any

Returns

string
core/rtml_for.go:259-280
func substituteItemFields(content, alias string, item map[string]any) string

{
	rawRegex := regexp.MustCompile(fmt.Sprintf(`@rawprop:%s\.(\w+(?:\.\w+)*)`, alias))
	content = rawRegex.ReplaceAllStringFunc(content, func(fieldMatch string) string {
		fieldParts := rawRegex.FindStringSubmatch(fieldMatch)
		if len(fieldParts) == 2 {
			if fieldValue, ok := resolveNestedKey(item, fieldParts[1]); ok {
				return fmt.Sprintf("%v", fieldValue)
			}
		}
		return fieldMatch
	})
	fieldRegex := regexp.MustCompile(fmt.Sprintf(`@prop:%s\.(\w+(?:\.\w+)*)`, alias))
	return fieldRegex.ReplaceAllStringFunc(content, func(fieldMatch string) string {
		fieldParts := fieldRegex.FindStringSubmatch(fieldMatch)
		if len(fieldParts) == 2 {
			if fieldValue, ok := resolveNestedKey(item, fieldParts[1]); ok {
				return escapeValue(fieldValue)
			}
		}
		return fieldMatch
	})
}
F
function

renderGolden

Parameters

name
string
tpl
string
props
map[string]any

Returns

string
core/rtml_golden_test.go:18-23
func renderGolden(t *testing.T, name, tpl string, props map[string]any) (string, *HTMLComponent)

{
	t.Helper()
	c := NewHTMLComponent(name, []byte(tpl), props)
	c.Init(nil)
	return c.Render(), c
}
F
function

expectGolden

Parameters

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

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

TestGoldenStoreDirectives

Parameters

core/rtml_golden_test.go:32-41
func TestGoldenStoreDirectives(t *testing.T)

{
	st := state.NewStore("g1", state.WithModule("app"))
	st.Set("v", "<i>x</i>")
	st.Set("m", "<i>y</i>")
	tpl := `<root><p>@store:app.g1.v</p><p>@rawstore:app.g1.m</p><input value="@store:app.g1.v:w"/></root>`
	got, c := renderGolden(t, "GoldenStore", tpl, nil)
	want := fmt.Sprintf(`<root data-component-id="%s"><p><span data-store="app.g1.v">&lt;i&gt;x&lt;/i&gt;</span></p><p><span data-store-raw="app.g1.m"><i>y</i></span></p><input value="@store:app.g1.v:w"/></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenSignalDirectives

Parameters

core/rtml_golden_test.go:43-50
func TestGoldenSignalDirectives(t *testing.T)

{
	sig := state.NewSignal("<b>s</b>")
	tpl := `<root><p>@signal:v</p><input value="@signal:v:w"/></root>`
	got, c := renderGolden(t, "GoldenSignal", tpl, map[string]any{"v": sig})
	want := fmt.Sprintf(`<root data-component-id="%s"><p><span data-signal="v">&lt;b&gt;s&lt;/b&gt;</span></p><input value="@signal:v:w"/></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenExprDirective

Parameters

core/rtml_golden_test.go:52-58
func TestGoldenExprDirective(t *testing.T)

{
	tpl := `<root><p>@expr:n + 1</p></root>`
	got, c := renderGolden(t, "GoldenExpr", tpl, map[string]any{"n": 2})
	want := fmt.Sprintf(`<root data-component-id="%s"><p><span data-expr="expr-0">3</span></p></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenClassExprDirective

Parameters

core/rtml_golden_test.go:60-66
func TestGoldenClassExprDirective(t *testing.T)

{
	tpl := `<root><p class="@expr:ok ? 'on' : 'off'">t</p></root>`
	got, c := renderGolden(t, "GoldenClassExpr", tpl, map[string]any{"ok": true})
	want := fmt.Sprintf(`<root data-component-id="%s"><p class="on" data-expr-class="class-expr-0">t</p></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenPropDirectives

Parameters

core/rtml_golden_test.go:68-74
func TestGoldenPropDirectives(t *testing.T)

{
	tpl := `<root><p>{{p}}</p><p>@prop:p</p><p>@rawprop:m</p><p>@prop:missing</p></root>`
	got, c := renderGolden(t, "GoldenProp", tpl, map[string]any{"p": "<u>p</u>", "m": "<u>m</u>"})
	want := fmt.Sprintf(`<root data-component-id="%s"><p>&lt;u&gt;p&lt;/u&gt;</p><p>&lt;u&gt;p&lt;/u&gt;</p><p><u>m</u></p><p>@prop:missing</p></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenIncludeDirective

Parameters

core/rtml_golden_test.go:76-87
func TestGoldenIncludeDirective(t *testing.T)

{
	child := NewHTMLComponent("GoldenIncChild", []byte(`<root><em>child</em></root>`), nil)
	tpl := `<root>@include:child</root>`
	c := NewHTMLComponent("GoldenInc", []byte(tpl), nil)
	c.Init(nil)
	c.AddDependency("child", child)
	got := c.Render()
	want := fmt.Sprintf(`<root data-component-id="%s"><root data-component-id="%s"><em>child</em></root>
</root>
`, c.ID, child.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenSlotDirective

Parameters

core/rtml_golden_test.go:89-106
func TestGoldenSlotDirective(t *testing.T)

{
	tpl := `<root><div>@slot:header fallback@endslot</div></root>`
	c := NewHTMLComponent("GoldenSlot", []byte(tpl), nil)
	c.Init(nil)
	c.SetSlots(map[string]any{"header": "provided"})
	got := c.Render()
	want := fmt.Sprintf(`<root data-component-id="%s"><div>provided</div></root>
`, c.ID)
	expectGolden(t, got, want)

	// Without provided content the inline fallback is rendered.
	c2 := NewHTMLComponent("GoldenSlotFallback", []byte(tpl), nil)
	c2.Init(nil)
	got2 := c2.Render()
	want2 := fmt.Sprintf(`<root data-component-id="%s"><div> fallback</div></root>
`, c2.ID)
	expectGolden(t, got2, want2)
}
F
function

TestGoldenForRangeDirective

Parameters

core/rtml_golden_test.go:108-114
func TestGoldenForRangeDirective(t *testing.T)

{
	tpl := `<root><ul>@for:i in 1..3 <li>@prop:i</li>@endfor</ul></root>`
	got, c := renderGolden(t, "GoldenForRange", tpl, nil)
	want := fmt.Sprintf(`<root data-component-id="%s"><ul> <li data-key="1">1</li> <li data-key="2">2</li> <li data-key="3">3</li></ul></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenForSliceDirective

Parameters

core/rtml_golden_test.go:116-124
func TestGoldenForSliceDirective(t *testing.T)

{
	st := state.NewStore("g2", state.WithModule("app"))
	st.Set("items", []any{map[string]any{"t": "<b>a</b>"}})
	tpl := `<root><ul>@for:i in store:app.g2.items <li>@prop:i.t</li>@endfor</ul></root>`
	got, c := renderGolden(t, "GoldenForSlice", tpl, nil)
	want := fmt.Sprintf(`<root data-component-id="%s"><ul><template data-for-anchor="for-%s-0"></template> <li data-key="0" data-for="for-%s-0">&lt;b&gt;a&lt;/b&gt;</li></ul></root>
`, c.ID, c.ID, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenForMapDirective

Parameters

core/rtml_golden_test.go:126-134
func TestGoldenForMapDirective(t *testing.T)

{
	st := state.NewStore("g3", state.WithModule("app"))
	st.Set("items", map[string]any{"k": "<b>v</b>"})
	tpl := `<root><ul>@for:k,v in store:app.g3.items <li>@prop:k=@prop:v</li>@endfor</ul></root>`
	got, c := renderGolden(t, "GoldenForMap", tpl, nil)
	want := fmt.Sprintf(`<root data-component-id="%s"><ul><template data-for-anchor="for-%s-0"></template> <li data-key="k" data-for="for-%s-0">k=&lt;b&gt;v&lt;/b&gt;</li></ul></root>
`, c.ID, c.ID, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenConditionalDirective

Parameters

core/rtml_golden_test.go:136-146
func TestGoldenConditionalDirective(t *testing.T)

{
	tpl := "<root>\n@if:prop:v==\"1\"\nOne\n@else-if:prop:v==\"2\"\nTwo\n@else\nOther\n@endif\n</root>"
	got, c := renderGolden(t, "GoldenIf", tpl, map[string]any{"v": "2"})
	conds := []string{`@if:prop:v=="1"`, `@else-if:prop:v=="2"`, ""}
	// the trailing index numbers the block within the render pass: two @if
	// blocks with the same condition are distinct blocks
	condHash := sha256.Sum256([]byte(strings.Join(conds, "|")))
	condID := fmt.Sprintf("cond-%x-0", condHash[:20])
	want := fmt.Sprintf("<root data-component-id=\"%s\">\n<div data-condition=\"%s\">Two\n</div></root>\n", c.ID, condID)
	expectGolden(t, got, want)
}
F
function

TestGoldenEventDirectives

Parameters

core/rtml_golden_test.go:148-154
func TestGoldenEventDirectives(t *testing.T)

{
	tpl := `<root><button @on:click:save>s</button><button @click.stop:undo>u</button></root>`
	got, c := renderGolden(t, "GoldenEvents", tpl, nil)
	want := fmt.Sprintf(`<root data-component-id="%s"><button data-on-click="save">s</button><button data-on-click="undo" data-on-click-modifiers="stop">u</button></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenRtIsDirective

Parameters

core/rtml_golden_test.go:156-169
func TestGoldenRtIsDirective(t *testing.T)

{
	if err := RegisterComponent("GoldenRtIsChild", func() Component {
		return NewHTMLComponent("GoldenRtIsChild", []byte(`<root><em>dyn</em></root>`), nil)
	}); err != nil && !strings.Contains(err.Error(), "already registered") {
		t.Fatalf("register: %v", err)
	}
	tpl := `<root><div rt-is="GoldenRtIsChild"></div></root>`
	got, c := renderGolden(t, "GoldenRtIs", tpl, nil)
	child := c.Dependencies["rtis-GoldenRtIsChild-0"].(*HTMLComponent)
	want := fmt.Sprintf(`<root data-component-id="%s"><root data-component-id="%s"><em>dyn</em></root>
</div></root>
`, c.ID, child.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenConstructorDirectives

Parameters

core/rtml_golden_test.go:171-177
func TestGoldenConstructorDirectives(t *testing.T)

{
	tpl := `<root><div [header] class="c"></div><li [key {i.ID}]></li><span [plugin:p.badge]></span></root>`
	got, c := renderGolden(t, "GoldenConstructors", tpl, nil)
	want := fmt.Sprintf(`<root data-component-id="%s"><div data-ref="header" class="c"></div><li data-key="{i.ID}"></li><span data-plugin="p.badge"></span></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenPluginDirectives

Parameters

core/rtml_golden_test.go:179-186
func TestGoldenPluginDirectives(t *testing.T)

{
	RegisterPluginVar("gplug", "team", "lions")
	tpl := `<root><div @plugin:gplug.init>{plugin:gplug.team}</div></root>`
	got, c := renderGolden(t, "GoldenPlugin", tpl, nil)
	want := fmt.Sprintf(`<root data-component-id="%s"><div data-plugin-cmd="gplug.init">lions</div></root>
`, c.ID)
	expectGolden(t, got, want)
}
F
function

TestGoldenHostDirectives

Parameters

core/rtml_golden_test.go:188-197
func TestGoldenHostDirectives(t *testing.T)

{
	tpl := `<root><p>{h:count}</p><button @h:reset>r</button></root>`
	c := NewHTMLComponent("GoldenHost", []byte(tpl), map[string]any{"count": "5"})
	c.Init(nil)
	c.AddHostComponent("GoldenHostComp")
	got := c.Render()
	want := fmt.Sprintf(`<root data-component-id="%s"><p><span data-host-var="count" data-host-expected="5">5</span></p><button data-host-cmd="reset">r</button></root>
`, c.ID)
	expectGolden(t, got, want)
}
S
struct
Implements: Component

HTMLComponent

HTMLComponent is the non-WASM component placeholder.

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

Methods

Stats
Method

Stats returns zeroed metrics on non-wasm builds.

Returns

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

Init attaches a state store and prepares the component template.

Parameters

store *state.Store
func (*HTMLComponent) Init(store *state.Store)
{
	if c.Store != nil {
		return
	}
	template, err := LoadComponentTemplate(c.TemplateFS)
	if err != nil {
		panic(fmt.Sprintf("Error loading template for component %s: %v", c.Name, err))
	}
	template = devOverrideTemplate(c, template)
	c.Template = template
	dom.RegisterBindings(c.ID, c.Name, template)
	devRegisterComponent(c)

	if store != nil {
		c.Store = store
	} else {
		c.Store = state.GlobalStoreManager.GetStore("app", "default")
		if c.Store == nil {
			c.Store = state.NewStore("default", state.WithModule("app"))
		}
	}
}
RenderFresh
Method

RenderFresh clears the render cache and re-renders. Reactive updates (store OnChange, signal effects) call this so a state change always produces up-to-date HTML instead of a stale cached render. Fixes the bug where a store.Set did not re-render @for / @expr / store-bound templates because the cache key hashes only Props/Dependencies, not the bound store state.

Returns

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

Invalidate drops the render cache of this component and of everything it includes. The cache key covers props and dependency identity, never the store state a template binds to, so an included component handed back its first render forever: a dependency whose markup depends on a shared store key (an @if on a global flag) froze at the value it had when the parent first painted.

func (*HTMLComponent) Invalidate()
{
	c.cache = nil
	c.lastCacheKey = ""
	for _, dep := range c.Dependencies {
		if d, ok := dep.(interface{ Invalidate() }); ok {
			d.Invalidate()
		}
	}
}
Render
Method

Render evaluates the component template.

Returns

renderedTemplate string
func (*HTMLComponent) Render() (renderedTemplate string)
{
	start := time.Now()
	defer func() { c.recordRender(time.Since(start)) }()
	key := c.cacheKey()
	if c.cache != nil {
		if val, ok := c.cache[key]; ok {
			renderedTemplate = val
			return
		}
		if c.lastCacheKey != "" && c.lastCacheKey != key {
			delete(c.cache, c.lastCacheKey)
		}
	} else {
		c.cache = make(map[string]string)
	}
	defer func() {
		if r := recover(); r != nil {
			ReportError(r, fmt.Sprintf("Render: %s (ID: %s)", c.Name, c.ID))
			renderedTemplate = ""
		}
	}()

	c.unsubscribes.Run()

	renderedTemplate = c.Template
	renderedTemplate = strings.Replace(renderedTemplate, "<root", fmt.Sprintf("<root data-component-id=\"%s\"", c.ID), 1)

	// Extract slot contents destined for child components
	renderedTemplate = extractSlotContents(renderedTemplate, c)

	// Replace this component's slot placeholders with provided content or fallbacks
	renderedTemplate = replaceSlotPlaceholders(renderedTemplate, c)

	// {{prop}} substitutions are HTML-escaped like @prop; @rawprop remains the
	// explicit escape hatch for trusted markup.
	for key, value := range c.Props {
		placeholder := fmt.Sprintf("{{%s}}", key)
		renderedTemplate = strings.ReplaceAll(renderedTemplate, placeholder, escapeValue(value))
	}

	// Register @include directives that supply inline props
	renderedTemplate = replaceComponentIncludes(renderedTemplate, c)

	// Handle @include:componentName syntax for dependencies
	renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)

	// Handle @for loops
	renderedTemplate = replaceForPlaceholders(renderedTemplate, c)

	renderedTemplate = replaceStorePlaceholders(renderedTemplate, c)
	renderedTemplate = replaceSignalPlaceholders(renderedTemplate, c)
	renderedTemplate = replaceExprInClassAttr(renderedTemplate, c)
	renderedTemplate = replaceExprPlaceholders(renderedTemplate, c)

	// Handle @prop:propName syntax for props
	renderedTemplate = replacePropPlaceholders(renderedTemplate, c)

	// Handle plugin variable and command placeholders
	renderedTemplate = replacePluginPlaceholders(renderedTemplate)

	// Handle host variable and command placeholders
	if len(c.hostComponentNames()) > 0 {
		renderedTemplate = replaceHostPlaceholders(renderedTemplate, c)
	}

	// Handle @if:condition syntax for conditional rendering
	renderedTemplate = replaceConditionals(renderedTemplate, c)

	// Handle @on:event:handler and @event:handler syntax for event binding
	renderedTemplate = replaceEventHandlers(renderedTemplate)

	// Handle rt-is="ComponentName" for dynamic component loading
	renderedTemplate = replaceRtIsAttributes(renderedTemplate, c)

	// Render any components introduced via rt-is placeholders
	renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)

	// Handle constructor decorators like [ref] and [key expr]
	renderedTemplate = replaceConstructors(renderedTemplate)

	for _, name := range c.hostComponentNames() {
		hostclient.RegisterComponent(c.ID, name, c.hostVars)
	}

	renderedTemplate = minifyInline(renderedTemplate)

	c.cache[key] = renderedTemplate
	c.lastCacheKey = key
	return renderedTemplate
}
recordRender
Method

Parameters

duration time.Duration
func (*HTMLComponent) recordRender(duration time.Duration)
{
	if c == nil {
		return
	}
	c.metricsMu.Lock()
	c.renderCount++
	c.totalRender += duration
	c.lastRender = duration
	c.appendTimelineLocked(ComponentTimelineEntry{
		Kind:      "render",
		Timestamp: time.Now(),
		Duration:  duration,
	})
	c.metricsMu.Unlock()
}

Parameters

func (*HTMLComponent) appendTimelineLocked(entry ComponentTimelineEntry)
{
	if entry.Kind == "" {
		return
	}
	if c.timeline == nil {
		c.timeline = make([]ComponentTimelineEntry, 0, 8)
	}
	c.timeline = append(c.timeline, entry)
	if len(c.timeline) > componentTimelineLimit {
		c.timeline = append([]ComponentTimelineEntry(nil), c.timeline[len(c.timeline)-componentTimelineLimit:]...)
	}
}
Stats
Method

Stats returns a snapshot of the component's render metrics.

Returns

func (*HTMLComponent) Stats() ComponentStats
{
	c.metricsMu.Lock()
	defer c.metricsMu.Unlock()
	stats := ComponentStats{
		RenderCount: c.renderCount,
		TotalRender: c.totalRender,
		LastRender:  c.lastRender,
	}
	if c.renderCount > 0 {
		stats.AverageRender = c.totalRender / time.Duration(c.renderCount)
	}
	if len(c.timeline) > 0 {
		stats.Timeline = append(stats.Timeline, c.timeline...)
	}
	return stats
}
AddDependency
Method

AddDependency attaches a child component to a template placeholder.

Parameters

placeholderName string
dep Component
func (*HTMLComponent) AddDependency(placeholderName string, dep Component)
{
	if c.Dependencies == nil {
		c.Dependencies = make(map[string]Component)
	}
	if depComp, ok := dep.(*HTMLComponent); ok {
		depComp.Init(c.Store)
		depComp.parent = c
	}
	c.Dependencies[placeholderName] = dep
}
Unmount
Method

Unmount releases component resources and child dependencies.

func (*HTMLComponent) Unmount()
{
	// The idempotence guard keeps finalizers from repeating lifecycle cleanup.
	if !c.mounted {
		return
	}
	c.mounted = false
	devUnregisterComponent(c)
	if c.component != nil {
		c.runLifecycle("OnUnmount", c.component.OnUnmount)
	}
	dom.UnmountLifecycleHooks(c.ID)
	c.releaseDOMHooks()
	if c.scope != nil {
		c.scope.Close()
	}

	dom.RemoveComponentSignals(c.ID)
	dom.ReleaseInputBindings(c.ID)
	dom.ReleaseComponentHandlers(c.ID)
	root := dom.ComponentRoot(c.ID)
	if !root.IsNull() && !root.IsUndefined() {
		dom.RemoveDelegatedEvents(c.ID, root.Value)
	}
	log.Printf("Unsubscribing %s from all stores", c.Name)
	c.unsubscribes.Run()

	for _, dep := range c.Dependencies {
		dependency := dep
		c.runLifecycle("dependency unmount", dependency.Unmount)
	}
}
Mount
Method

Mount activates the component and its child dependencies.

func (*HTMLComponent) Mount()
{
	c.mounted = true
	if c.scope == nil || c.scope.Closed() {
		c.scope = NewScope()
	}
	c.registerHandlers()
	c.registerDOMHooks()
	for _, dep := range c.Dependencies {
		dependency := dep
		c.runLifecycle("dependency mount", dependency.Mount)
	}
	root := dom.ComponentRoot(c.ID)
	if !root.IsNull() && !root.IsUndefined() {
		dom.DelegateEvents(c.ID, root.Value)
	}
	if c.component != nil {
		c.runLifecycle("OnMount", c.component.OnMount)
	}
	dom.MountLifecycleHooks(c.ID)
}
runLifecycle
Method

Parameters

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

Scope returns the lifecycle scope owned by this component.

Returns

func (*HTMLComponent) Scope() *Scope
{
	if c.scope == nil || c.scope.Closed() {
		c.scope = NewScope()
	}
	return c.scope
}
Effect
Method

Effect registers a reactive effect that stops on unmount.

Parameters

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

DOMHook registers root lifecycle callbacks owned by this component.

Parameters

func (*HTMLComponent) DOMHook(hook dom.LifecycleHook)
{
	c.domHooks = append(c.domHooks, hook)
	if c.mounted {
		c.domHookStops = append(c.domHookStops, dom.RegisterLifecycleHook(c.ID, hook))
		dom.MountLifecycleHooks(c.ID)
	}
}
func (*HTMLComponent) registerDOMHooks()
{
	c.releaseDOMHooks()
	for _, hook := range c.domHooks {
		c.domHookStops = append(c.domHookStops, dom.RegisterLifecycleHook(c.ID, hook))
	}
}
func (*HTMLComponent) releaseDOMHooks()
{
	for _, stop := range c.domHookStops {
		stop()
	}
	c.domHookStops = nil
}
On
Method

On registers an event handler owned by this component instance.

Parameters

name string
fn func()
func (*HTMLComponent) On(name string, fn func())
{
	if name == "" {
		panic("core.HTMLComponent.On: empty handler name")
	}
	if fn == nil {
		panic("core.HTMLComponent.On: nil fn")
	}
	c.handlers[name] = fn
	dom.RegisterComponentHandlerFunc(c.ID, name, fn)
}
func (*HTMLComponent) registerHandlers()
{
	for name, fn := range c.handlers {
		dom.RegisterComponentHandlerFunc(c.ID, name, fn)
	}
}
GetName
Method

GetName returns the component name.

Returns

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

GetID returns the component identifier.

Returns

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

GetRef returns the DOM element annotated with a matching constructor decorator. It searches within this component's root element using the data-ref attribute injected during template rendering.

Parameters

name string

Returns

func (*HTMLComponent) GetRef(name string) dom.Element
{
	root := dom.ComponentRoot(c.ID)
	if root.IsNull() || root.IsUndefined() {
		return dom.Element{}
	}
	return root.Query(fmt.Sprintf(`[data-ref="%s"]`, name))
}
OnMount
Method

OnMount runs the configured mount callback.

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

OnUnmount runs the configured unmount callback.

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

IsMounted reports whether the component is mounted.

Returns

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

OnParams runs the configured route-parameter callback.

Parameters

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

SetOnParams configures the route-parameter callback.

Parameters

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

SetOnMount configures the mount callback.

Parameters

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

SetOnUnmount configures the unmount callback.

Parameters

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

WithLifecycle configures mount and unmount callbacks.

Parameters

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

Returns

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

SetComponent attaches the component lifecycle implementation.

Parameters

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

SetSlots merges named slot content into the component.

Parameters

slots map[string]any
func (*HTMLComponent) SetSlots(slots map[string]any)
{
	if c.Slots == nil {
		c.Slots = make(map[string]any)
	}
	for k, v := range slots {
		c.Slots[k] = v
	}
}
Provide
Method

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

Parameters

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

Inject searches for a provided value starting from this component and walking up the parent chain. It returns the value as `any` and whether it was found. Callers can type-assert the result.

Parameters

key string

Returns

any
bool
func (*HTMLComponent) Inject(key string) (any, bool)
{
	if c.provides != nil {
		if v, ok := c.provides[key]; ok {
			return v, true
		}
	}
	if c.parent != nil {
		return c.parent.Inject(key)
	}
	return nil, false
}

SetRouteParams merges route parameters into component props.

Parameters

params map[string]string
func (*HTMLComponent) SetRouteParams(params map[string]string)
{
	if c.Props == nil {
		c.Props = make(map[string]any)
	}
	for k, v := range params {
		c.Props[k] = v
	}
}

AddHostComponent links this HTML component to a server-side HostComponent by name. When running in SSC mode, messages from the wasm runtime will be routed to the corresponding host component on the server. It may be called multiple times (e.g. a composition struct with several host fields): every name is registered, and HostComponent keeps the first one as the primary.

Parameters

name string
func (*HTMLComponent) AddHostComponent(name string)
{
	for _, n := range c.hostComponents {
		if n == name {
			return
		}
	}
	c.hostComponents = append(c.hostComponents, name)
	if c.HostComponent == "" {
		c.HostComponent = name
	}
}

hostComponentNames returns every host component linked to this component, including a HostComponent assigned directly to the exported field.

Returns

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

Returns

string
func (*HTMLComponent) cacheKey() string
{
	hasher := sha256.New()
	hasher.Write([]byte(serializeProps(c.Props)))

	if len(c.Dependencies) > 0 {
		deps := make([]string, 0, len(c.Dependencies))
		for name, dep := range c.Dependencies {
			deps = append(deps, name+dep.GetID())
		}
		sort.Strings(deps)
		for _, d := range deps {
			hasher.Write([]byte(d))
		}
	}

	return hex.EncodeToString(hasher.Sum(nil)[:20])
}
Render
Method

Render returns no markup outside WASM.

Returns

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

Mount performs no work outside WASM.

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

Unmount performs no work outside WASM.

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

OnMount performs no work outside WASM.

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

OnUnmount performs no work outside WASM.

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

GetName returns the component name.

Returns

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

GetID returns the component ID.

Returns

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

SetSlots performs no work outside WASM.

Parameters

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

Scope returns the component lifecycle scope.

Returns

func (*HTMLComponent) Scope() *Scope
{
	if c.scope == nil || c.scope.Closed() {
		c.scope = NewScope()
	}
	return c.scope
}

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

fragment string

Returns

string
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
S
struct
Implements: Component

panicComponent

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

Methods

Render
Method

Returns

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

Returns

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

Returns

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

Parameters

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

Returns

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

Parameters

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

TestErrorBoundaryRender

Parameters

core/error_boundary_test.go:20-35
func TestErrorBoundaryRender(t *testing.T)

{
	eb := NewErrorBoundary(&panicComponent{}, "<div>fb</div>")
	html := eb.Render()
	expected := "<root data-component-id=\"panic\"><div>fb</div></root>"
	if html != expected {
		t.Fatalf("expected %s, got %s", expected, html)
	}
	eb.Mount()
	if !eb.IsMounted() {
		t.Fatal("boundary fallback should be mounted")
	}
	eb.Unmount()
	if eb.IsMounted() {
		t.Fatal("boundary should not be mounted after Unmount")
	}
}
F
function

ShowErrorOverlay

ShowErrorOverlay displays a styled error recovery UI in the browser when a
panic occurs. It categorizes the error, shows the Go stack trace, and
provides actionable hints based on the panic message.

Parameters

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

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

errorOverlay

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

Methods

show
Method

Parameters

err any
context string
func (*errorOverlay) show(err any, context string)
{
	errStr := fmt.Sprintf("%v", err)
	goStack := string(debug.Stack())

	if !eo.shown {
		eo.shown = true
		eo.createContainer(errStr, goStack, context)
		return
	}

	eo.errCount++
	doc := js.Document()
	list := doc.Call("getElementById", "rfw-error-list")
	if !list.Truthy() {
		return
	}
	item := doc.Call("createElement", "div")
	item.Get("style").Set("borderTop", "1px solid #e5e7eb")
	item.Set("innerHTML", eo.buildErrorItem(eo.errCount, errStr, goStack, context))
	list.Call("appendChild", item)
}

Parameters

errStr string
goStack string
context string
func (*errorOverlay) createContainer(errStr, goStack, context string)
{
	doc := js.Document()
	body := doc.Get("body")

	overlay := doc.Call("createElement", "div")
	overlay.Set("id", "rfw-error-overlay")
	style := overlay.Get("style")
	style.Set("position", "fixed")
	style.Set("top", "0")
	style.Set("left", "0")
	style.Set("width", "100%")
	style.Set("height", "100%")
	style.Set("backgroundColor", "rgba(0,0,0,0.85)")
	style.Set("zIndex", "999999")
	style.Set("display", "flex")
	style.Set("alignItems", "center")
	style.Set("justifyContent", "center")
	style.Set("padding", "20px")
	style.Set("boxSizing", "border-box")
	style.Set("fontFamily", "system-ui,-apple-system,sans-serif")

	card := doc.Call("createElement", "div")
	cs := card.Get("style")
	cs.Set("background", "#ffffff")
	cs.Set("borderRadius", "12px")
	cs.Set("boxShadow", "0 20px 60px rgba(0,0,0,0.3)")
	cs.Set("maxWidth", "900px")
	cs.Set("width", "100%")
	cs.Set("maxHeight", "90vh")
	cs.Set("overflow", "auto")
	cs.Set("display", "flex")
	cs.Set("flexDirection", "column")

	card.Set("innerHTML", eo.buildMainHTML(errStr, goStack, context))
	overlay.Call("appendChild", card)
	body.Call("appendChild", overlay)

	eo.bindActions(overlay)
	eo.container = overlay
}
bindActions
Method

Parameters

func (*errorOverlay) bindActions(js.Value)
{
	doc := js.Document()

	reload := doc.Call("getElementById", "rfw-error-reload")
	if reload.Truthy() {
		reload.Call("addEventListener", "click", js.SafeFuncOf(func(_ js.Value, _ []js.Value) any {
			js.Location().Call("reload")
			return nil
		}))
	}

	copyBtn := doc.Call("getElementById", "rfw-error-copy")
	if copyBtn.Truthy() {
		copyBtn.Call("addEventListener", "click", js.SafeFuncOf(func(_ js.Value, _ []js.Value) any {
			pre := doc.Call("getElementById", "rfw-error-full")
			if pre.Truthy() {
				text := pre.Get("textContent").String()
				navigator := js.Global().Get("navigator")
				if clipboard := navigator.Get("clipboard"); clipboard.Truthy() {
					clipboard.Call("writeText", text)
				}
			}
			return nil
		}))
	}
}
buildMainHTML
Method

Parameters

errStr string
goStack string
context string

Returns

string
func (*errorOverlay) buildMainHTML(errStr, goStack, context string) string
{
	cat := eo.categorize(errStr)
	hint := eo.hintHTML(errStr, context)

	versionStr := Version()
	if versionStr == "" {
		versionStr = "dev"
	}

	return fmt.Sprintf(`
<div style="padding:24px 24px 0;">
    <div style="display:flex;align-items:flex-start;gap:12px;margin-bottom:16px;">
        <div style="flex:1;">
            <div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:#6b7280;font-weight:700;">%s</div>
            <h2 style="margin:4px 0 0;font-size:20px;color:#111827;font-weight:800;">Something went wrong</h2>
        </div>
    </div>
    <div style="background:#fef2f2;border-left:4px solid #ef4444;border-radius:6px;padding:16px;margin-bottom:16px;">
        <div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;color:#991b1b;word-break:break-word;line-height:1.5;">%s</div>
    </div>
</div>
%s
<div style="padding:0 24px;">
    <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;">
        <button id="rfw-error-reload" style="background:#111827;color:#fff;border:none;border-radius:6px;padding:10px 18px;cursor:pointer;font-size:14px;font-weight:500;">Reload Page</button>
        <button id="rfw-error-copy" style="background:#f3f4f6;color:#374151;border:none;border-radius:6px;padding:10px 18px;cursor:pointer;font-size:14px;font-weight:500;">Copy Error</button>
    </div>
</div>
<div style="padding:0 24px 16px;">
    <div style="font-size:13px;font-weight:700;color:#374151;margin-bottom:8px;">Stack Trace</div>
    <pre style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:12px;overflow-x:auto;font-size:11px;color:#4b5563;margin:0;line-height:1.5;">%s</pre>
</div>
<pre id="rfw-error-full" style="display:none;">%s</pre>
<div id="rfw-error-list" style="display:none;"></div>
<div style="padding:8px 24px 16px;text-align:center;">
    <div style="font-size:11px;color:#9ca3af;">
        rfw recovery mode &middot; %s
    </div>
</div>
    `, cat, htmlEscape(errStr), hint,
		htmlEscape(goStack),
		htmlEscape(fmt.Sprintf("Error: %s\nContext: %s\n\n%s", errStr, context, goStack)),
		versionStr)
}

Parameters

n int
errStr string
goStack string
_ string

Returns

string
func (*errorOverlay) buildErrorItem(n int, errStr, goStack, _ string) string
{
	return fmt.Sprintf(`
<div style="padding:16px;">
    <div style="font-size:12px;font-weight:700;color:#6b7280;margin-bottom:8px;">Error #%d</div>
    <div style="background:#fef2f2;border-radius:6px;padding:12px;margin-bottom:8px;">
        <div style="font-family:monospace;font-size:12px;color:#991b1b;word-break:break-word;">%s</div>
    </div>
    <details open>
        <summary style="cursor:pointer;font-size:12px;color:#6b7280;">Stack trace</summary>
        <pre style="background:#f9fafb;border-radius:6px;padding:8px;font-size:11px;color:#4b5563;margin-top:8px;">%s</pre>
    </details>
</div>
    `, n, htmlEscape(errStr), htmlEscape(goStack))
}
categorize
Method

Parameters

err string

Returns

string
func (*errorOverlay) categorize(err string) string
{
	errLower := strings.ToLower(err)
	switch {
	case strings.Contains(errLower, "template"):
		return "Template Error"
	case strings.Contains(errLower, "signal"):
		return "Signal Error"
	case strings.Contains(errLower, "store"):
		return "Store Error"
	case strings.Contains(errLower, "nil pointer") || strings.Contains(errLower, "invalid memory"):
		return "Null Reference"
	case strings.Contains(errLower, "index out of range"):
		return "Index Error"
	case strings.Contains(errLower, "mount") || strings.Contains(errLower, "render") || strings.Contains(errLower, "unmount"):
		return "Lifecycle Error"
	case strings.Contains(errLower, "dom") || strings.Contains(errLower, "element"):
		return "DOM Error"
	default:
		return "Runtime Error"
	}
}
hintHTML
Method

Parameters

errStr string
_ string

Returns

string
func (*errorOverlay) hintHTML(errStr, _ string) string
{
	errLower := strings.ToLower(errStr)
	hints := []string{}

	if strings.Contains(errLower, "template") && strings.Contains(errLower, "not found") {
		hints = append(hints, `Call <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">composition.RegisterFS(&amp;yourEmbedFS)</code> or add a <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">Template() string</code> method to your struct.`)
	}
	if strings.Contains(errLower, "signal") && strings.Contains(errLower, "not found") {
		hints = append(hints, `Use a signal type field (<code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">t.Int</code>, <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">*t.String</code>, etc.) and initialize with <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">t.NewInt(0)</code>.`)
	}
	if strings.Contains(errLower, "nil pointer") || strings.Contains(errLower, "invalid memory") {
		hints = append(hints, `Initialize all pointer fields. Use <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">*t.Inject[T]</code> for DI dependencies.`)
	}
	if strings.Contains(errLower, "store") && strings.Contains(errLower, "not found") {
		hints = append(hints, `Register store with <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">state.GlobalStoreManager.RegisterStore()</code>.`)
	}
	if strings.Contains(errLower, "index out of range") {
		hints = append(hints, `Check bounds: <code style="background:#f3f4f6;padding:2px 5px;border-radius:3px;font-size:12px;">if len(items) > i { ... }</code>.`)
	}
	if strings.Contains(errLower, "dom") || strings.Contains(errLower, "element") {
		hints = append(hints, `Ensure element exists before access. Check component mount order.`)
	}

	if len(hints) == 0 {
		return ""
	}

	var sb strings.Builder
	sb.WriteString(`<div style="padding:0 24px 16px;"><div style="background:#eff6ff;border-radius:6px;padding:16px;">`)
	sb.WriteString(`<div style="font-size:13px;font-weight:700;color:#1e40af;margin-bottom:8px;">How to fix this</div>`)
	sb.WriteString(`<ul style="margin:0;padding-left:20px;font-size:13px;color:#374151;line-height:1.7;">`)
	for _, h := range hints {
		fmt.Fprintf(&sb, "<li>%s</li>", h)
	}
	sb.WriteString("</ul></div></div>")
	return sb.String()
}

Fields

Name Type Description
shown bool
container js.Value
errCount int
F
function

htmlEscape

Parameters

s
string

Returns

string
core/error_overlay.go:238-244
func htmlEscape(s string) string

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

errPipeComponent

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

Methods

Render
Method

Returns

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

newErrPipeComponent

Returns

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

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

TestReportErrorFansOutToSinks

Parameters

core/errors_test.go:21-47
func TestReportErrorFansOutToSinks(t *testing.T)

{
	var got []string
	stop := OnError(func(_ any, ctx string) {
		got = append(got, ctx)
	})
	defer stop()

	TryRender(newErrPipeComponent())
	if len(got) != 1 || !strings.HasPrefix(got[0], "Render: Boom") {
		t.Fatalf("expected render report, got %v", got)
	}

	b := NewErrorBoundary(newErrPipeComponent(), "<p>fallback</p>")
	out := b.Render()
	if !strings.Contains(out, "fallback") {
		t.Fatalf("expected fallback html, got %q", out)
	}
	if len(got) != 2 || !strings.HasPrefix(got[1], "Boundary render: Boom") {
		t.Fatalf("expected boundary report, got %v", got)
	}

	stop()
	TryRender(newErrPipeComponent())
	if len(got) != 2 {
		t.Fatalf("sink should be removed, got %v", got)
	}
}
F
function

TestElseIfRendering

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

Parameters

core/rtml_test.go:14-32
func TestElseIfRendering(t *testing.T)

{
	c := &HTMLComponent{Props: map[string]any{"val": "2"}, conditionContents: make(map[string]ConditionContent)}
	template := `
@if:prop:val=="1"
One
@else-if:prop:val=="2"
Two
@else
Other
@endif`

	out := replaceConditionals(template, c)
	if strings.Contains(out, "One") || strings.Contains(out, "Other") {
		t.Fatalf("unexpected branches rendered: %s", out)
	}
	if !strings.Contains(out, "Two") {
		t.Fatalf("expected 'Two' branch, got %s", out)
	}
}
F
function

TestNestedConditionals

Parameters

core/rtml_test.go:34-58
func TestNestedConditionals(t *testing.T)

{
	props := map[string]any{"outer": "yes", "inner": "maybe"}
	c := &HTMLComponent{Props: props, conditionContents: make(map[string]ConditionContent)}
	template := `
@if:prop:outer=="yes"
Start
    @if:prop:inner=="yes"
        InnerYes
    @else-if:prop:inner=="maybe"
        InnerMaybe
    @else
        InnerNo
    @endif
@else
    OuterNo
@endif`

	out := replaceConditionals(template, c)
	if !strings.Contains(out, "Start") || !strings.Contains(out, "InnerMaybe") {
		t.Fatalf("nested conditions not rendered as expected: %s", out)
	}
	if strings.Contains(out, "InnerYes") || strings.Contains(out, "InnerNo") || strings.Contains(out, "OuterNo") {
		t.Fatalf("unexpected branches present: %s", out)
	}
}
F
function

TestReplaceConstructors

Tests constructor decorators for refs and keyed lists.

Parameters

core/rtml_test.go:61-73
func TestReplaceConstructors(t *testing.T)

{
	tpl := `<div [header] class="box"></div>`
	out := replaceConstructors(tpl)
	if !strings.Contains(out, `data-ref="header"`) || !strings.Contains(out, `class="box"`) {
		t.Fatalf("unexpected constructor replacement: %s", out)
	}

	tpl = `<li [key {item.ID}]></li>`
	out = replaceConstructors(tpl)
	if out != `<li data-key="{item.ID}"></li>` {
		t.Fatalf("expected data-key constructor, got %s", out)
	}
}
F
function

TestPluginPlaceholders

Tests plugin placeholders for variables, commands and constructors.

Parameters

core/rtml_test.go:76-92
func TestPluginPlaceholders(t *testing.T)

{
	RegisterPluginVar("soccer", "team", "lions")
	tpl := `<div @plugin:soccer.init>{plugin:soccer.team}</div>`
	out := replacePluginPlaceholders(tpl)
	if !strings.Contains(out, "lions") {
		t.Fatalf("plugin variable not replaced: %s", out)
	}
	if !strings.Contains(out, `data-plugin-cmd="soccer.init"`) {
		t.Fatalf("plugin command not replaced: %s", out)
	}

	tpl = `<span [plugin:soccer.badge]></span>`
	out = replaceConstructors(tpl)
	if !strings.Contains(out, `data-plugin="soccer.badge"`) {
		t.Fatalf("plugin constructor not replaced: %s", out)
	}
}
F
function

TestForUnsetStoreKeyRendersNothing

A @for bound to a store key that was never set must render nothing: the raw
template row used to leak into the DOM and keyed patches never removed it.

Parameters

core/rtml_test.go:96-107
func TestForUnsetStoreKeyRendersNothing(t *testing.T)

{
	state.NewStore("fornil", state.WithModule("app"))
	tpl := []byte(`<root><div>@for:i in store:app.fornil.items
<span data-key="x">@prop:i.title</span>
@endfor</div></root>`)
	c := NewHTMLComponent("ForNil", tpl, nil)
	c.Init(nil)
	html := c.Render()
	if strings.Contains(html, "@prop") || strings.Contains(html, "@for") {
		t.Fatalf("unset store key leaked template markup: %s", html)
	}
}
F
function

TestForFieldsEscapedByDefault

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

Parameters

core/rtml_test.go:110-125
func TestForFieldsEscapedByDefault(t *testing.T)

{
	st := state.NewStore("escfor", state.WithModule("app"))
	st.Set("items", []any{map[string]any{"txt": "<img src=x>", "markup": "<b>ok</b>"}})
	tpl := []byte(`<root><div>@for:i in store:app.escfor.items
<span data-key="k">@prop:i.txt|@rawprop:i.markup</span>
@endfor</div></root>`)
	c := NewHTMLComponent("EscFor", tpl, nil)
	c.Init(nil)
	html := c.Render()
	if !strings.Contains(html, "&lt;img src=x&gt;") {
		t.Fatalf("field not escaped: %s", html)
	}
	if !strings.Contains(html, "<b>ok</b>") {
		t.Fatalf("rawprop escaped: %s", html)
	}
}
F
function

TestSignalEscapedByDefault

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

Parameters

core/rtml_test.go:129-141
func TestSignalEscapedByDefault(t *testing.T)

{
	sig := state.NewSignal("<img src=x onerror=alert(1)>")
	tpl := []byte(`<root><div>@signal:v</div></root>`)
	c := NewHTMLComponent("EscSignal", tpl, map[string]any{"v": sig})
	c.Init(nil)
	html := c.Render()
	if !strings.Contains(html, "&lt;img src=x onerror=alert(1)&gt;") {
		t.Fatalf("signal not escaped: %s", html)
	}
	if strings.Contains(html, "<img") {
		t.Fatalf("signal injected markup: %s", html)
	}
}
F
function

TestExprEscapedByDefault

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

Parameters

core/rtml_test.go:144-155
func TestExprEscapedByDefault(t *testing.T)

{
	tpl := []byte(`<root><div>@expr:msg</div></root>`)
	c := NewHTMLComponent("EscExpr", tpl, map[string]any{"msg": "<b>bad</b>"})
	c.Init(nil)
	html := c.Render()
	if !strings.Contains(html, "&lt;b&gt;bad&lt;/b&gt;") {
		t.Fatalf("expr not escaped: %s", html)
	}
	if strings.Contains(html, "<b>bad</b>") {
		t.Fatalf("expr injected markup: %s", html)
	}
}
F
function

TestSignalUpdateUsesTextContent

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

Parameters

core/rtml_test.go:158-178
func TestSignalUpdateUsesTextContent(t *testing.T)

{
	sig := state.NewSignal("safe")
	tpl := []byte(`<root><div>@signal:v</div></root>`)
	c := NewHTMLComponent("EscSignalUpdate", tpl, map[string]any{"v": sig})
	c.Init(nil)
	html := c.Render()
	container := dom.Doc().CreateElement("div")
	container.Set("innerHTML", html)
	dom.Doc().Get("body").Call("appendChild", container.Value)
	defer container.Call("remove")
	sig.Set("<img src=x onerror=alert(1)>")
	root := dom.ComponentRoot(c.ID)
	node := root.Query(`[data-signal="v"]`)
	if node.IsNull() || node.IsUndefined() {
		t.Fatalf("signal binding not found")
	}
	inner := node.Get("innerHTML").String()
	if !strings.Contains(inner, "&lt;img") {
		t.Fatalf("signal update not applied as text: %s", inner)
	}
}
F
function

TestCurlyPropEscapedByDefault

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

Parameters

core/rtml_test.go:182-196
func TestCurlyPropEscapedByDefault(t *testing.T)

{
	tpl := []byte(`<root><div>{{msg}}|@rawprop:markup</div></root>`)
	c := NewHTMLComponent("EscCurly", tpl, map[string]any{
		"msg":    "<script>alert(1)</script>",
		"markup": "<b>ok</b>",
	})
	c.Init(nil)
	html := c.Render()
	if !strings.Contains(html, "&lt;script&gt;alert(1)&lt;/script&gt;") {
		t.Fatalf("curly prop not escaped: %s", html)
	}
	if !strings.Contains(html, "<b>ok</b>") {
		t.Fatalf("rawprop escaped: %s", html)
	}
}
F
function

TestForMapFieldsEscapedByDefault

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

Parameters

core/rtml_test.go:200-220
func TestForMapFieldsEscapedByDefault(t *testing.T)

{
	st := state.NewStore("escformap", state.WithModule("app"))
	st.Set("items", map[string]any{
		"<key>": map[string]any{"txt": "<img src=x>", "markup": "<b>ok</b>"},
	})
	tpl := []byte(`<root><div>@for:k,v in store:app.escformap.items
<span data-key="m">@prop:k|@prop:v.txt|@rawprop:v.markup</span>
@endfor</div></root>`)
	c := NewHTMLComponent("EscForMap", tpl, nil)
	c.Init(nil)
	html := c.Render()
	if !strings.Contains(html, "&lt;key&gt;") {
		t.Fatalf("map key not escaped: %s", html)
	}
	if !strings.Contains(html, "&lt;img src=x&gt;") {
		t.Fatalf("map field not escaped: %s", html)
	}
	if !strings.Contains(html, "<b>ok</b>") {
		t.Fatalf("rawprop escaped: %s", html)
	}
}
F
function

TestForMapScalarsEscapedByDefault

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

Parameters

core/rtml_test.go:223-235
func TestForMapScalarsEscapedByDefault(t *testing.T)

{
	st := state.NewStore("escformapscalar", state.WithModule("app"))
	st.Set("items", map[string]any{"a": "<i>x</i>"})
	tpl := []byte(`<root><div>@for:k,v in store:app.escformapscalar.items
<span data-key="s">@prop:v</span>
@endfor</div></root>`)
	c := NewHTMLComponent("EscForMapScalar", tpl, nil)
	c.Init(nil)
	html := c.Render()
	if !strings.Contains(html, "&lt;i&gt;x&lt;/i&gt;") {
		t.Fatalf("map scalar not escaped: %s", html)
	}
}
F
function

TestStoreEscapedByDefault

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

Parameters

core/rtml_test.go:238-252
func TestStoreEscapedByDefault(t *testing.T)

{
	st := state.NewStore("escstore", state.WithModule("app"))
	st.Set("v", "<i>x</i>")
	st.Set("m", "<i>y</i>")
	tpl := []byte(`<root><div>@store:app.escstore.v @rawstore:app.escstore.m</div></root>`)
	c := NewHTMLComponent("EscStore", tpl, nil)
	c.Init(nil)
	html := c.Render()
	if !strings.Contains(html, "&lt;i&gt;x&lt;/i&gt;") {
		t.Fatalf("store not escaped: %s", html)
	}
	if !strings.Contains(html, "<i>y</i>") {
		t.Fatalf("rawstore escaped: %s", html)
	}
}
F
function

TestNamedSlotExtraction

Parameters

core/slot_test.go:12-30
func TestNamedSlotExtraction(t *testing.T)

{
	childTpl := []byte("<root>@slot:avatar<div>default</div>@endslot</root>")
	parentTpl := []byte("<root>@slot:child.avatar<img src=\"pic.png\"/>@endslot@include:child</root>")

	store := state.NewStore("test")
	parent := NewHTMLComponent("Parent", parentTpl, nil)
	parent.Init(store)
	child := NewHTMLComponent("Child", childTpl, nil)
	// child Init will be called via AddDependency
	parent.AddDependency("child", child)

	html := parent.Render()
	if strings.Contains(html, ".avatar") {
		t.Fatalf("slot placeholder not removed: %s", html)
	}
	if !strings.Contains(html, "pic.png") {
		t.Fatalf("slot content not injected: %s", html)
	}
}
F
function

TestIncludePlaceholderPrefixCollision

Parameters

core/slot_test.go:32-51
func TestIncludePlaceholderPrefixCollision(t *testing.T)

{
	childTpl := []byte("<root>@slot:avatar<div>fallback-avatar</div>@endslot<div>@slot<p>fallback-details</p>@endslot</div></root>")
	parentTpl := []byte("<root>@slot:card.avatar<img/>@endslot@slot:card<p>details</p>@endslot@include:card@include:cardFallback</root>")

	store := state.NewStore("test2")
	parent := NewHTMLComponent("Parent2", parentTpl, nil)
	parent.Init(store)
	card := NewHTMLComponent("Child", childTpl, nil)
	fallback := NewHTMLComponent("Child", childTpl, nil)
	parent.AddDependency("card", card)
	parent.AddDependency("cardFallback", fallback)

	html := parent.Render()
	if strings.Count(html, "<img/>") != 1 {
		t.Fatalf("expected one image only: %s", html)
	}
	if !strings.Contains(html, "fallback-avatar") || !strings.Contains(html, "fallback-details") {
		t.Fatalf("fallback content missing: %s", html)
	}
}
F
function

TestComponentInstancesHaveDistinctIDs

Parameters

core/slot_test.go:53-60
func TestComponentInstancesHaveDistinctIDs(t *testing.T)

{
	first := NewHTMLComponent("Repeated", []byte("<root></root>"), nil)
	second := NewHTMLComponent("Repeated", []byte("<root></root>"), nil)

	if first.ID == second.ID {
		t.Fatalf("component instances share ID %q", first.ID)
	}
}
T
type

ErrorSink

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

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

OnError

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

Parameters

Returns

func()
core/errors.go:29-44
func OnError(fn ErrorSink) func()

{
	if fn == nil {
		return func() {}
	}
	errorMu.Lock()
	errorSinks = append(errorSinks, fn)
	idx := len(errorSinks) - 1
	errorMu.Unlock()
	return func() {
		errorMu.Lock()
		if idx < len(errorSinks) {
			errorSinks[idx] = nil
		}
		errorMu.Unlock()
	}
}
F
function

ReportError

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

Parameters

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

init

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

core/errors.go:63-67
func init()

{
	dom.OnHandlerPanic = func(err any, name string) {
		ReportError(err, "Handler: "+name)
	}
}
F
function

TestConditionalBranchRefreshesWhenItComesBack

A block that is hidden while the state it binds to moves on has to come back
showing the current value, not the one it carried when it left the DOM.

Parameters

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

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

loopID
string
aliases
[]string
loopContent
string
collection
any

Returns

bool
core/rtml_for_patch.go:20-61
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
}
F
function

incrementalForBody

incrementalForBody reports whether a loop body is self-contained enough to be
patched without a full render.

Parameters

body
string

Returns

bool
core/rtml_for_patch.go:65-72
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)
}
F
function

mountForComponent

Parameters

name
string
tpl
[]byte

Returns

core/rtml_for_patch_test.go:14-26
func mountForComponent(t *testing.T, name string, tpl []byte) *HTMLComponent

{
	t.Helper()
	host := dom.Doc().CreateElement("div")
	dom.Doc().Body().AppendChild(host)
	t.Cleanup(func() { host.Call("remove") })

	c := NewHTMLComponent(name, tpl, nil)
	c.SetComponent(c)
	c.Init(nil)
	host.SetHTML(c.Render())
	c.Mount()
	return c
}
F
function

TestForPatchLeavesSiblingsAlone

A list that changes should cost its own rows, not a re-render of everything
around it: the sibling markup keeps its node identity.

Parameters

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

TestForPatchRendersEveryRow

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

Parameters

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

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

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

TestForPatchRebindsInputsOnce

Parameters

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

expectOneStoreSet

Parameters

sets
<-chan string
want
string
core/rtml_for_patch_test.go:158-173
func expectOneStoreSet(t *testing.T, sets <-chan string, want string)

{
	t.Helper()
	select {
	case got := <-sets:
		if got != want {
			t.Fatalf("input updated store key %q, want %q", got, want)
		}
	case <-time.After(time.Second):
		t.Fatal("input did not update the store")
	}
	select {
	case got := <-sets:
		t.Fatalf("input updated store key %q more than once", got)
	case <-time.After(20 * time.Millisecond):
	}
}
F
function

TestForPatchRebindsSignalInputsOnce

Parameters

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

expectOneSignalSet

Parameters

sets
<-chan string
want
string
core/rtml_for_patch_test.go:217-232
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):
	}
}