Huge PR. 135 commits here to rebuild waveapps into the "Tsunami" framework.
* Simplified API
* Updated system.md prompt
* Basic applications building and running
* /api/config and /api/data support
* tailwind styling
* no need for async updates
* goroutine/timer primitives for async routing handling
* POC for integrating 3rd party react frameworks (recharts)
* POC for server side components (table.go)
* POC for interacting with apps via /api/config (tsunamiconfig)
Checkpoint. Still needs to be tightly integrated with Wave (lifecycle, AI interaction, etc.) but looking very promising 🚀
9.9 KiB
Tsunami Rendering Engine
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.
Core Architecture
Two-Phase VDom System
Tsunami uses separate types for different phases of the rendering pipeline:
- VDomElem: Input format used by developers (JSX-like elements created with
vdom.H()) - ComponentImpl: Internal shadow tree that maintains component identity and state across renders
- RenderedElem: Output format sent to the frontend with populated WaveIds
This separation mirrors React's approach where JSX elements, Fiber nodes, and DOM operations use different data structures optimized for their specific purposes.
ComponentImpl: The Shadow Tree
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:
- Identity fields: WaveId (unique identifier), Tag (component type), Key (for reconciliation)
- State management: Hooks array for React-like state and effects
- Content organization: Exactly one of three mutually exclusive patterns
Three Component Patterns
The engine organizes components into three distinct patterns, each using different fields in ComponentImpl:
Pattern 1: Text Components
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"
Pattern 2: Base/DOM Elements
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:
- HTML tags with lowercase first letter (
"div","span","button") - Hash-prefixed special elements (
"#fragment","#text") - Wave-specific elements (
"wave:text","wave:null")
Pattern 3: Custom Components
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.]
Rendering Flow
1. Reconciliation and Pattern Routing
The main render() function performs React-like reconciliation:
- Null handling:
elem == nilunmounts the component - Component matching: Existing components are reused if tag and key match
- Pattern routing: Elements are routed to the appropriate pattern based on tag type
if 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)
}
2. Pattern-Specific Rendering
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).
3. Component Function Execution
Custom components are Go functions called via reflection:
- Props conversion: The VDomElem props map is converted to the expected Go struct type
- Function execution: The component function is called with context and typed props
- Result processing: Returned elements are converted to VDomElem arrays
- Fragment wrapping: Multiple returned elements are automatically wrapped in fragments
// 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}
}
Key-Based Reconciliation
The children reconciliation system implements React's key-matching logic:
ChildKey Structure
type ChildKey struct {
Tag string // Component type must match
Idx int // Position index for non-keyed elements
Key string // Explicit key for keyed elements
}
Matching Rules
-
Keyed elements: Match by tag + key, position ignored
<div key="a">only matches<div key="a">- Position changes don't break identity
-
Non-keyed elements: Match by tag + position
<div>at position 0 only matches<div>at position 0- Moving elements breaks identity and causes remount
-
Key transitions: Keyed and non-keyed elements never match
<div>→<div key="hello">causes remount- Adding/removing keys breaks component identity
Reconciliation Algorithm
// 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
}
Component Lifecycle
Mounting
New components are created with:
- Unique WaveId for tracking
- Tag and Key for reconciliation
- Registration in global ComponentMap
- Empty pattern fields (populated during rendering)
Unmounting
The unmounting process ensures complete cleanup:
- Hook cleanup: All hook
UnmountFncallbacks are executed - Pattern-specific cleanup:
- Pattern 3: Recursively unmount
RenderedComp - Pattern 2: Recursively unmount all
Children - Pattern 1: No child cleanup needed
- Pattern 3: Recursively unmount
- Global cleanup: Remove from ComponentMap and dependency tracking
This prevents memory leaks and ensures proper lifecycle management.
Component vs Rendered Content Lifecycle
A key distinction in Tsunami (matching React) is that component mounting/unmounting is separate from what they render:
- Component returns
nil: Component stays mounted (keeps state/hooks), butRenderedCompbecomesnil - Component returns content again: Component reuses existing identity, new content gets mounted
This preserves component state across rendering/not-rendering cycles.
Output Generation
The shadow tree gets converted to frontend-ready format through MakeRendered():
- Component chain following: For Pattern 3 components, follow
RenderedCompuntil reaching a base element - Base element conversion: Convert Pattern 1/2 components to RenderedElem with WaveIds
- Null component filtering: Components with
RenderedComp == nildon't appear in output
Only base elements (Pattern 1/2) appear in the final output - custom components (Pattern 3) are invisible, having transformed into base elements.
React Similarities and Differences
Similarities
- Reconciliation: Same key-based matching and component reuse logic
- Hooks: Same lifecycle patterns with cleanup functions
- Component identity: Persistent component instances across renders
- Null rendering: Components can render nothing while staying mounted
Key Differences
- Server-side: Runs entirely in Go backend, sends VDom to frontend
- Component chaining: Pattern 3 allows direct component-to-component rendering via
RenderedComp - Explicit patterns: Three mutually exclusive patterns vs React's more flexible structure
- Type separation: Clear separation between input VDom, shadow tree, and output types
Performance Optimizations
The three-pattern system provides significant optimizations:
- Base element efficiency: HTML elements use
Childrendirectly without intermediate transformation nodes - Component chain efficiency: Custom components chain via
RenderedCompwithout wrapper overhead - Memory efficiency: Each pattern only allocates fields it actually uses
This avoids React's issue where every element creates wrapper nodes, leading to shorter traversal paths and fewer allocations.
Pattern Transition Rules
Components never transition between patterns - they maintain their pattern for their entire lifecycle:
- Tag determines pattern:
#text→ Pattern 1, base tags → Pattern 2, custom tags → Pattern 3 - Tag changes cause remount: Different tag = different component = complete unmount/remount
- Pattern fields are exclusive: Only one pattern's fields are populated per component
This ensures clean memory management and predictable behavior - no cross-pattern cleanup is needed within individual render functions.