waveterm/tsunami/engine/render.md
Mike Sawka e7cd584659
tsunami framework (waveapps v2) (#2315)
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 🚀
2025-09-11 14:25:07 -07:00

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:

  1. Null handling: elem == nil unmounts the component
  2. Component matching: Existing components are reused if tag and key match
  3. 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:

  1. Props conversion: The VDomElem props map is converted to the expected Go struct type
  2. Function execution: The component function is called with context and typed props
  3. Result processing: Returned elements are converted to VDomElem arrays
  4. 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

  1. Keyed elements: Match by tag + key, position ignored

    • <div key="a"> only matches <div key="a">
    • Position changes don't break identity
  2. Non-keyed elements: Match by tag + position

    • <div> at position 0 only matches <div> at position 0
    • Moving elements breaks identity and causes remount
  3. 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:

  1. Hook cleanup: All hook UnmountFn callbacks are executed
  2. Pattern-specific cleanup:
    • Pattern 3: Recursively unmount RenderedComp
    • Pattern 2: Recursively unmount all Children
    • Pattern 1: No child cleanup needed
  3. 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), but RenderedComp becomes nil
  • 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():

  1. Component chain following: For Pattern 3 components, follow RenderedComp until reaching a base element
  2. Base element conversion: Convert Pattern 1/2 components to RenderedElem with WaveIds
  3. Null component filtering: Components with RenderedComp == nil don'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 Children directly without intermediate transformation nodes
  • Component chain efficiency: Custom components chain via RenderedComp without 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.