tsunami/engine/render.md
The Tsunami rendering engine implements a React-like component system with virtual DOM reconciliation. It maintains a persistent shadow component tree that efficiently updates in response to new VDom input, similar to React's Fiber architecture.
Tsunami uses separate types for different phases of the rendering pipeline:
vdom.H())This separation mirrors React's approach where JSX elements, Fiber nodes, and DOM operations use different data structures optimized for their specific purposes.
The ComponentImpl structure is Tsunami's equivalent to React's Fiber nodes. It maintains a persistent tree that survives between renders, preserving component identity, state, and lifecycle information.
Each ComponentImpl contains:
The engine organizes components into three distinct patterns, each using different fields in ComponentImpl:
Text string // Text content (Pattern 1: text nodes only)
Children = nil // Not used
RenderedComp = nil // Not used
Used for #text components that render string content directly. These are the leaf nodes of the component tree.
Example: vdom.H("#text", nil, "Hello World") creates a ComponentImpl with Text = "Hello World"
Text = "" // Not used
Children []*ComponentImpl // Child components (Pattern 2: containers only)
RenderedComp = nil // Not used
Used for HTML elements, fragments, and Wave-specific elements that act as containers. These components render multiple children but don't transform into other component types.
Example: vdom.H("div", nil, child1, child2) creates a ComponentImpl with Children = [child1Comp, child2Comp]
Base elements include:
"div", "span", "button")"#fragment", "#text")"wave:text", "wave:null")Text = "" // Not used
Children = nil // Not used
RenderedComp *ComponentImpl // Rendered output (Pattern 3: custom components only)
Used for user-defined components that transform into other components through their render functions. These create component chains where custom components render to base elements.
Example: A TodoItem component renders to a div, creating the chain:
TodoItem ComponentImpl (Pattern 3)
└── RenderedComp → div ComponentImpl (Pattern 2)
└── Children → [text, button, etc.]
The main render() function performs React-like reconciliation:
elem == nil unmounts the componentif elem.Tag == vdom.TextTag {
// Pattern 1: Text Nodes
r.renderText(elem.Text, comp)
} else if isBaseTag(elem.Tag) {
// Pattern 2: Base elements
r.renderSimple(elem, comp, opts)
} else {
// Pattern 3: Custom components
r.renderComponent(cfunc, elem, comp, opts)
}
Each pattern has its own rendering function that manages field usage:
renderText(): Simply stores text content, no cleanup needed since text components can't have other patterns.
renderSimple(): Clears any existing RenderedComp (Pattern 3) and renders children into the Children field (Pattern 2).
renderComponent(): Clears any existing Children (Pattern 2), calls the component function, and renders the result into RenderedComp (Pattern 3).
Custom components are Go functions called via reflection:
// Single element: renders directly to RenderedComp
// Multiple elements: wrapped in fragment, then rendered to RenderedComp
if len(rtnElemArr) == 1 {
rtnElem = &rtnElemArr[0]
} else {
rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr}
}
The children reconciliation system implements React's key-matching logic:
type ChildKey struct {
Tag string // Component type must match
Idx int // Position index for non-keyed elements
Key string // Explicit key for keyed elements
}
Keyed elements: Match by tag + key, position ignored
<div key="a"> only matches <div key="a">Non-keyed elements: Match by tag + position
<div> at position 0 only matches <div> at position 0Key transitions: Keyed and non-keyed elements never match
<div> → <div key="hello"> causes remount// Build map of existing children by ChildKey
for idx, child := range curChildren {
if child.Key != "" {
curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child
} else {
curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: ""}] = child
}
}
// Match new elements against existing map
for idx, elem := range elems {
elemKey := getElemKey(&elem)
if elemKey != "" {
curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}]
} else {
curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}]
}
// Reuse existing component or create new one
}
New components are created with:
The unmounting process ensures complete cleanup:
UnmountFn callbacks are executedRenderedCompChildrenThis prevents memory leaks and ensures proper lifecycle management.
A key distinction in Tsunami (matching React) is that component mounting/unmounting is separate from what they render:
nil: Component stays mounted (keeps state/hooks), but RenderedComp becomes nilThis preserves component state across rendering/not-rendering cycles.
The shadow tree gets converted to frontend-ready format through MakeRendered():
RenderedComp until reaching a base elementRenderedComp == nil don't appear in outputOnly base elements (Pattern 1/2) appear in the final output - custom components (Pattern 3) are invisible, having transformed into base elements.
RenderedCompThe three-pattern system provides significant optimizations:
Children directly without intermediate transformation nodesRenderedComp without wrapper overheadThis avoids React's issue where every element creates wrapper nodes, leading to shorter traversal paths and fewer allocations.
Components never transition between patterns - they maintain their pattern for their entire lifecycle:
#text → Pattern 1, base tags → Pattern 2, custom tags → Pattern 3This ensures clean memory management and predictable behavior - no cross-pattern cleanup is needed within individual render functions.