vdom

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 28, 2026 License: MIT Imports: 10 Imported by: 0

README

vdom — Virtual DOM in Go

A pure-Go virtual DOM implementation with tree construction, diffing, and patch generation. Used internally by godom but designed as a self-contained package.

What it does

This package lets you:

  1. Build virtual DOM trees from Go structs (TextNode, ElementNode, KeyedElementNode, etc.)
  2. Parse HTML templates with directives (g-if, g-for, g-click, etc.) into reusable template trees
  3. Resolve templates against Go struct state to produce concrete VNode trees with stable node IDs
  4. Diff two VNode trees to compute the minimal set of patches needed to transform one into the other
  5. Inspect patches to apply them to any rendering target (browser DOM, terminal, test assertions, etc.)

Core concepts

Node interface

Every virtual DOM node implements:

type Node interface {
    NodeType() int           // which node variant (text, element, keyed, etc.)
    NodeID() int             // stable identity assigned during ResolveTree()
    DescendantsCount() int   // total descendant count (cached by ComputeDescendants)
}
NodeBase

All concrete node types embed NodeBase, which provides the stable identity and descendant count:

type NodeBase struct {
    ID          int // stable identity, assigned during ResolveTree(), used to address the node
    Descendants int // cached count, set by ComputeDescendants
}

IDs are assigned by a monotonic IDCounter during ResolveTree(). The counter never resets across renders, ensuring existing IDs remain valid in the rendering engine's node map.

Node types
Go type Constant Description
*TextNode NodeText Leaf node containing plain text
*ElementNode NodeElement HTML/SVG element with a tag, facts, and ordered children
*KeyedElementNode NodeKeyed Like ElementNode but children have stable string keys for efficient reordering
*PluginNode NodePlugin An opaque node whose rendering is delegated to an external system (e.g. a JS library)
*LazyNode NodeLazy Deferred computation — if the function and args haven't changed, the entire subtree is skipped
Facts (element metadata)

Facts groups everything about an element that isn't its tag or children:

type Facts struct {
    Props   map[string]any          // DOM properties: className, value, checked, id
    Attrs   map[string]string       // HTML attributes: data-*, aria-*, role, etc.
    AttrsNS map[string]NSAttr       // Namespaced attributes (SVG): xlink:href, xml:lang
    Styles  map[string]string       // Inline CSS: background-color, width, etc.
    Events  map[string]EventHandler // Event listeners: click, input, keydown, etc.
}

Why group them? Because the diff algorithm can diff all of them in one pass (DiffFacts()) and produce a single FactsDiff with only the changed/added/removed entries.

EventHandler
type EventHandler struct {
    Handler string       // method name to call (e.g. "Save", "Toggle")
    Args    []any        // pre-resolved arguments
    Scope   string       // routing info for child components (e.g. "g3:2")
    Options EventOptions // key filter, stopPropagation, preventDefault
}

type EventOptions struct {
    StopPropagation bool
    PreventDefault  bool
    Key             string // key filter for keydown events
}

Events are declarative — the vdom tree says "when this element is clicked, call Save with these args." The runtime decides how to wire that up.


Building trees by hand

You can construct VNode trees directly without using the template parser:

package main

import "github.com/anupshinde/godom/internal/vdom"

func main() {
    // A simple tree: <div class="app"><p>Hello</p><p>World</p></div>
    tree := &vdom.ElementNode{
        NodeBase: vdom.NodeBase{ID: 1},
        Tag:      "div",
        Facts: vdom.Facts{
            Props: map[string]any{"className": "app"},
        },
        Children: []vdom.Node{
            &vdom.ElementNode{
                NodeBase: vdom.NodeBase{ID: 2},
                Tag:      "p",
                Children: []vdom.Node{&vdom.TextNode{NodeBase: vdom.NodeBase{ID: 3}, Text: "Hello"}},
            },
            &vdom.ElementNode{
                NodeBase: vdom.NodeBase{ID: 4},
                Tag:      "p",
                Children: []vdom.Node{&vdom.TextNode{NodeBase: vdom.NodeBase{ID: 5}, Text: "World"}},
            },
        },
    }

    // IMPORTANT: compute descendant counts before diffing
    vdom.ComputeDescendants(tree)
}

When using the template system, IDs are assigned automatically by ResolveTree() via the IDCounter in ResolveContext. When building trees by hand, you assign IDs manually.

Keyed children (for lists)

When children have stable identifiers, use KeyedElementNode for efficient reordering:

list := &vdom.KeyedElementNode{
    NodeBase: vdom.NodeBase{ID: 10},
    Tag:      "ul",
    Children: []vdom.KeyedChild{
        {Key: "id-1", Node: &vdom.ElementNode{NodeBase: vdom.NodeBase{ID: 11}, Tag: "li", Children: []vdom.Node{&vdom.TextNode{NodeBase: vdom.NodeBase{ID: 12}, Text: "Alice"}}}},
        {Key: "id-2", Node: &vdom.ElementNode{NodeBase: vdom.NodeBase{ID: 13}, Tag: "li", Children: []vdom.Node{&vdom.TextNode{NodeBase: vdom.NodeBase{ID: 14}, Text: "Bob"}}}},
        {Key: "id-3", Node: &vdom.ElementNode{NodeBase: vdom.NodeBase{ID: 15}, Tag: "li", Children: []vdom.Node{&vdom.TextNode{NodeBase: vdom.NodeBase{ID: 16}, Text: "Carol"}}}},
    },
}

When you reorder, insert, or remove keyed children, the differ detects exactly what moved and produces PatchReorder instead of redrawing everything.

Lazy nodes (skip unchanged subtrees)
renderSidebar := func(items []string) vdom.Node {
    // ... expensive tree construction ...
    return &vdom.ElementNode{Tag: "aside", Children: /* ... */}
}

lazy := &vdom.LazyNode{
    NodeBase: vdom.NodeBase{ID: 20},
    Func:     renderSidebar,
    Args:     []any{items}, // compared by reference equality
}

If Func pointer and all Args are reference-equal to the previous render, the entire subtree is skipped — zero computation.

Plugin nodes (external rendering)
chart := &vdom.PluginNode{
    NodeBase: vdom.NodeBase{ID: 30},
    Tag:      "canvas",             // host element
    Name:     "chartjs",            // plugin identifier
    Facts: vdom.Facts{
        Attrs: map[string]string{"width": "400", "height": "300"},
    },
    Data: map[string]any{       // JSON-serializable data for the plugin
        "type": "bar",
        "data": chartData,
    },
}

The differ JSON-compares Data — if it changed, it emits a PatchPlugin with the new data.


Diffing two trees

// Build the old tree (with IDs — normally assigned by ResolveTree)
oldTree := &vdom.ElementNode{
    NodeBase: vdom.NodeBase{ID: 1},
    Tag:      "div",
    Children: []vdom.Node{&vdom.TextNode{NodeBase: vdom.NodeBase{ID: 2}, Text: "Hello"}},
}
vdom.ComputeDescendants(oldTree)

// Build the new tree (text changed, fresh IDs)
newTree := &vdom.ElementNode{
    NodeBase: vdom.NodeBase{ID: 3},
    Tag:      "div",
    Children: []vdom.Node{&vdom.TextNode{NodeBase: vdom.NodeBase{ID: 4}, Text: "Goodbye"}},
}
vdom.ComputeDescendants(newTree)

// Diff
patches := vdom.Diff(oldTree, newTree)

// patches will contain one entry:
//   Patch{Type: PatchText, NodeID: 2, Data: PatchTextData{Text: "Goodbye"}}
//
// NodeID 2 is the old text node's ID — the rendering engine looks up this ID
// in its node map to find the DOM node to update.
The Patch struct
type Patch struct {
    Type   int // one of the Patch* constants
    NodeID int // ID of the target node in the OLD tree
    Data   any // type-specific payload (see below)
}

The differ always uses the old tree's node IDs for patches, because those are the IDs the rendering engine already has in its node map.

Patch types
Constant Payload type When emitted
PatchRedraw PatchRedrawData{Node} Node type changed or element tag/namespace changed
PatchText PatchTextData{Text} Text node content changed
PatchFacts PatchFactsData{Diff} Properties, attributes, styles, or events changed
PatchAppend PatchAppendData{Nodes} New children added at the end
PatchRemoveLast PatchRemoveLastData{Count} N children removed from the end
PatchReorder PatchReorderData{Inserts, Removes, Patches} Keyed children inserted, removed, or moved
PatchPlugin PatchPluginData{Data} Plugin data changed (JSON comparison)
PatchLazy PatchLazyData{Patches} Wrapper for patches inside a lazy node's subtree
FactsDiff

When element metadata changes, the diff contains only what changed:

type FactsDiff struct {
    Props   map[string]any           // changed/added props (nil value = removed)
    Attrs   map[string]string        // changed/added attrs ("" = removed)
    AttrsNS map[string]NSAttr        // changed/added namespaced attrs
    Styles  map[string]string        // changed/added styles ("" = removed)
    Events  map[string]*EventHandler // changed/added events (nil = removed)
}

Example: if only className changed from "active" to "inactive", the FactsDiff will be:

FactsDiff{
    Props: map[string]any{"className": "inactive"},
    // Attrs, Styles, Events are all nil — unchanged
}
Stable identity addressing

Patches reference their target node by a stable node ID (NodeID). Each node gets a unique ID assigned during ResolveTree() via a monotonic IDCounter that never resets across renders. The differ uses the old tree's IDs because those are the IDs the rendering engine (bridge) already knows.

New nodes in the new tree get fresh IDs. When the bridge receives a patch, it looks up the target DOM node using nodeMap[nodeID].

Important: You must call ComputeDescendants() on both trees before calling Diff().


Template system

The template system parses HTML with g-* directives into a reusable template tree, then resolves it against Go struct state on each render cycle.

Parsing
html := `<div>
    <h1 g-text="Title"></h1>
    <p>Welcome, {{Name}}!</p>
    <ul>
        <li g-for="item in Items" g-key="item.ID">
            <span g-text="item.Text"></span>
            <button g-click="Remove(item.ID)">Delete</button>
        </li>
    </ul>
    <p g-if="ShowFooter">That's all!</p>
</div>`

templates, err := vdom.ParseTemplate(html)

ParseTemplate() returns []*TemplateNode — a tree of template nodes that can be resolved repeatedly against different state values. This is parsed once and reused.

Template node structure
type TemplateNode struct {
    // Element nodes
    Tag        string
    Namespace  string           // "http://www.w3.org/2000/svg" for SVG elements
    Attrs      []html.Attribute // static HTML attributes (non-directive)
    Directives []Directive      // extracted g-* directives
    Children   []*TemplateNode

    // Text nodes
    IsText    bool
    TextParts []TextPart // mix of static text and {{expr}} interpolations

    // g-for loop nodes
    IsFor    bool
    ForItem  string           // loop variable name: "item"
    ForIndex string           // index variable name: "i" (empty if unused)
    ForList  string           // list field: "Items"
    ForKey   string           // key expression: "item.ID" (empty = positional)
    ForBody  []*TemplateNode  // template for each iteration

    // Plugin nodes
    IsPlugin   bool
    PluginName string
    PluginExpr string
}
Directives

Directives are extracted from g-* attributes during parsing:

type Directive struct {
    Type string // "text", "bind", "value", "checked", "if", "show", "hide", "class", "attr", "style",
               // "click", "keydown", "mousedown", "mousemove", "mouseup", "wheel",
               // "drop", "draggable", "dropzone"
    Name string // modifier: class name, style property, key filter, drag group, etc.
    Expr string // expression: field name, method call, boolean literal, etc.
}
Directive HTML syntax Effect on resolved tree
g-text g-text="Name" Sets the element's children to a single text node with the resolved value
g-bind g-bind="Input" Sets value prop + adds an input event handler for two-way binding
g-checked g-checked="Done" Sets the checked prop to true/false
g-if g-if="ShowPanel" If falsy, the entire node is excluded from the resolved tree
g-show g-show="Visible" If falsy, adds display: none style (node stays in tree)
g-class:name g-class:active="IsActive" Conditionally appends a CSS class to className
g-attr:name g-attr:data-id="ID" Sets an HTML attribute to the resolved value
g-style:prop g-style:color="TextColor" Sets an inline CSS property
g-click g-click="Save" Adds a click event handler
g-keydown g-keydown="Enter:Submit" Adds a keydown handler with optional key filter
g-mousedown g-mousedown="Start" Mouse button event (passes coordinates)
g-mousemove g-mousemove="OnMove" Mouse move event (throttled to rAF in the browser)
g-mouseup g-mouseup="End" Mouse button release event
g-wheel g-wheel="OnScroll" Wheel/scroll event (passes deltaY)
g-for g-for="todo in Todos" Loop: renders the element body once per slice item
g-key g-key="todo.ID" Stable key for g-for list diffing (avoids positional redraw)
g-draggable g-draggable="ID" Makes the element draggable with a payload value
g-draggable:group g-draggable:tasks="ID" Draggable within a named group
g-dropzone g-dropzone="OnDrop" Registers a drop target
g-drop g-drop="HandleDrop" Drop event handler
g-plugin:name g-plugin:chart="Data" Delegates rendering to a named plugin
Resolving templates against state
type MyApp struct {
    Title string
    Name  string
    Items []Item
    ShowFooter bool
}

type Item struct {
    ID   int
    Text string
}

app := &MyApp{
    Title: "My List",
    Name:  "Alice",
    Items: []Item{{ID: 1, Text: "Buy milk"}, {ID: 2, Text: "Write code"}},
    ShowFooter: true,
}

// IDCounter must persist across renders — never reset it.
ids := &vdom.IDCounter{}

ctx := &vdom.ResolveContext{
    State: reflect.ValueOf(app).Elem(), // must be the struct value (not pointer)
    Vars:  map[string]any{},            // loop variables (empty at top level)
    IDs:   ids,                         // assigns unique IDs to each node
}

nodes := vdom.ResolveTree(templates, ctx)

This produces a concrete []Node tree where:

  • Every node has a unique ID from the IDCounter
  • {{Name}} is replaced with "Alice"
  • g-text="Title" creates a text child node "My List"
  • g-for="item in Items" is unrolled into two <li> elements
  • g-if="ShowFooter" includes the <p> node (since ShowFooter is true)
  • g-click="Remove(item.ID)" creates an EventHandler with the resolved ID value
Expression resolution

ResolveExpr(expr, ctx) resolves expressions using a two-tier approach:

Fast path (direct reflection — used for the majority of expressions):

  1. Simple fields: "Score"ctx.State.FieldByName("Score")
  2. Loop variables: "todo"ctx.Vars["todo"]
  3. Dotted paths: "todo.Text" → loop variable lookup + field walk via reflection
  4. Negation: "!Visible" → fast path + IsTruthy inversion
  5. Zero-arg methods: "Summary()"ctx.State.MethodByName("Summary").Call(nil)
  6. Bracket map access: "Inputs[key]" → direct map index lookup

expr-lang path (for expressions with operators):

  • Comparisons: "Status == 'active'", "Count > 0", "Score >= Threshold"
  • Logical operators: "IsAdmin and IsActive", "not Done", "A or B"
  • Powered by expr-lang/expr

Compiled expr-lang programs are cached (sync.Map keyed by expression string). The base environment (struct fields + methods) is built once per render and shared across all expressions. See expr-lang docs for the full expression syntax.

Text interpolation

Text like "Hello, {{Name}}! You have {{Count}} items." is parsed into parts:

parts := vdom.ParseTextInterpolations("Hello, {{Name}}! You have {{Count}} items.")
// Result:
// []TextPart{
//     {Static: true,  Value: "Hello, "},
//     {Static: false, Value: "Name"},
//     {Static: true,  Value: "! You have "},
//     {Static: false, Value: "Count"},
//     {Static: true,  Value: " items."},
// }

During resolution, non-static parts are evaluated via ResolveExpr().

Loop expression parsing
item, index, list := vdom.ParseForExpr("todo, i in Todos")
// item="todo", index="i", list="Todos"

item, index, list = vdom.ParseForExpr("item in Items")
// item="item", index="", list="Items"
Method call parsing
method, args := vdom.ParseMethodCall("Remove(i, todo.ID)")
// method="Remove", args=["i", "todo.ID"]

method, args = vdom.ParseMethodCall("Save")
// method="Save", args=nil

Full example: parse, resolve, diff

package main

import (
    "fmt"
    "reflect"

    "github.com/anupshinde/godom/internal/vdom"
)

type Counter struct {
    Count int
}

func main() {
    html := `<div><p>Count: {{Count}}</p></div>`

    templates, _ := vdom.ParseTemplate(html)

    // ID counter persists across renders — never reset.
    ids := &vdom.IDCounter{}

    // --- First render ---
    state1 := &Counter{Count: 0}
    ctx1 := &vdom.ResolveContext{
        State: reflect.ValueOf(state1).Elem(),
        Vars:  map[string]any{},
        IDs:   ids,
    }
    tree1 := vdom.ResolveTree(templates, ctx1)
    root1 := &vdom.ElementNode{Tag: "body", Children: tree1}
    vdom.ComputeDescendants(root1)

    // --- Second render (count changed) ---
    state2 := &Counter{Count: 5}
    ctx2 := &vdom.ResolveContext{
        State: reflect.ValueOf(state2).Elem(),
        Vars:  map[string]any{},
        IDs:   ids, // same counter — new nodes get fresh IDs
    }
    tree2 := vdom.ResolveTree(templates, ctx2)
    root2 := &vdom.ElementNode{Tag: "body", Children: tree2}
    vdom.ComputeDescendants(root2)

    // --- Diff ---
    patches := vdom.Diff(root1, root2)

    for _, p := range patches {
        switch p.Type {
        case vdom.PatchText:
            d := p.Data.(vdom.PatchTextData)
            fmt.Printf("Text change at node %d: %q\n", p.NodeID, d.Text)
        case vdom.PatchFacts:
            fmt.Printf("Facts change at node %d\n", p.NodeID)
        case vdom.PatchRedraw:
            fmt.Printf("Redraw at node %d\n", p.NodeID)
        case vdom.PatchAppend:
            d := p.Data.(vdom.PatchAppendData)
            fmt.Printf("Append %d children at node %d\n", len(d.Nodes), p.NodeID)
        case vdom.PatchRemoveLast:
            d := p.Data.(vdom.PatchRemoveLastData)
            fmt.Printf("Remove last %d children at node %d\n", d.Count, p.NodeID)
        }
    }
    // Output: Text change at node 3: "Count: 5"
}

Truthiness

IsTruthy() determines whether a value is "truthy" for g-if, g-show, and g-class:

Type Falsy when
nil always
bool false
int, int64, float64 0
string ""
slice, map Len() == 0
everything else never (always truthy)

Helper functions

Function Purpose
ComputeDescendants(node) Recursively calculates and caches descendant counts. Must be called before Diff().
MergeAdjacentText(nodes) Collapses consecutive TextNodes into one and drops empty text nodes. Called automatically by ResolveTree().
CopyVars(vars) Shallow-copies a variable map (used internally by g-for to create child contexts).
DeepCopyJSON(v) Deep-copies a value via JSON round-trip (used for plugin data isolation).

File layout

internal/vdom/
├── node.go        Node interface, NodeBase, all node types, Facts, EventHandler, ComputeDescendants
├── tree.go        IDCounter, ResolveContext, ParseTemplate(), ResolveTree(), ResolveExpr(),
│                  text interpolation, for-loop parsing, method call parsing, IsTruthy, helpers
├── diff.go        Diff(), DiffFacts(), keyed diff algorithm, equality helpers
├── patch.go       Patch struct, FactsDiff, all patch payload structs
├── merge.go       MergeTree(), MergeAdjacentText() — tree merging utilities
├── node_test.go   Tests for node types and ComputeDescendants
├── tree_test.go   Tests for parsing, resolution, text interpolation, for expressions
├── diff_test.go   Tests for diffing, keyed diffing, facts diffing, NodeID targeting
└── merge_test.go  Tests for tree merging and adjacent text merging

Design decisions

No virtual DOM reconciliation

godom uses positional diffing for non-keyed children and key-based matching for keyed children. There is no heuristic tree matching (React-style) because the template structure is static — parsed once at startup — so the old and new trees always have the same shape. Only the data changes.

Stable identity patch addressing

Patches reference nodes by stable node ID rather than positional index. Each node gets a unique ID during ResolveTree() from a monotonic counter that persists across renders. The differ uses the old tree's IDs because those are what the rendering engine already has in its node map. This avoids the fragility of DFS-index addressing where tree mutations invalidate subsequent indices.

Facts as a unified concept

Grouping props, attrs, namespaced attrs, styles, and events into one Facts struct means the differ handles all of them in a single DiffFacts() call. The result is a FactsDiff with only the changes, which maps directly to the browser's DOM API (set property, set attribute, set style, add/remove event listener).

Lazy nodes use reference equality

LazyNode compares the function pointer and args by reference (reflect.ValueOf().Pointer()), not by value. If nothing changed by reference, the subtree is skipped entirely with zero work. This is the primary optimization for large trees with mostly-static sections.

Template tree is immutable after parse

ParseTemplate() runs once. Every render cycle resolves the same template tree against new state, which is fast because the structure is known and fixed — only expressions are evaluated.

Adjacent text nodes are merged

After resolving g-for loops (which can produce empty text nodes from whitespace) and g-if conditionals (which can remove nodes between text), MergeAdjacentText() collapses consecutive text nodes. This prevents the differ from generating spurious text patches for whitespace-only nodes.

Documentation

Overview

Package vdom implements a virtual DOM tree with diffing and patching. It handles template parsing, tree resolution, diffing, and patch generation. The godom runtime uses this package for its rendering pipeline.

Index

Constants

View Source
const (
	NodeText    = iota // plain text content
	NodeElement        // HTML/SVG element with tag, facts, children
	NodeKeyed          // element with keyed children (for efficient list diffing)
	NodePlugin         // opaque JS-managed node (plugin escape hatch)
	NodeLazy           // deferred computation, skipped if inputs unchanged
)

Node type constants identify each Node variant.

View Source
const (
	PatchRedraw     = iota // replace entire node
	PatchText              // update text content
	PatchFacts             // update properties/attributes/styles/events
	PatchAppend            // add children at end
	PatchRemoveLast        // remove N children from end
	PatchRemove            // remove specific child (keyed)
	PatchReorder           // reorder keyed children (inserts/removes/moves)
	PatchPlugin            // plugin data changed
	PatchLazy              // wrapper for patches inside a lazy node
)

Patch operation constants.

Variables

This section is empty.

Functions

func BuildExprEnv

func BuildExprEnv(ctx *ResolveContext) map[string]any

BuildExprEnv returns the expr-lang environment for the current context. The base env (struct fields + methods) is built once per render and cached. Loop variables are merged on top each call.

func ComputeDescendants

func ComputeDescendants(n Node) int

ComputeDescendants recursively calculates and caches descendant counts. Must be called after building a tree and before diffing.

func CopyVars

func CopyVars(vars map[string]any) map[string]any

CopyVars creates a shallow copy of a variable map.

func DeepCopyJSON

func DeepCopyJSON(v any) any

DeepCopyJSON deep-copies a value by JSON round-tripping.

func IsTruthy

func IsTruthy(val any) bool

IsTruthy returns whether a value is considered true for g-if/g-show/g-class.

func IsValidIdentifier

func IsValidIdentifier(name string) bool

IsValidIdentifier checks that a name looks like a Go/JS identifier: letters, digits, underscores; cannot start with a digit.

func MarkRemoved

func MarkRemoved(n Node)

MarkRemoved recursively marks a node and all its descendants as removed.

func MergeTree

func MergeTree(dst, src Node) map[int]int

MergeTree updates dst in place with data from src, keeping dst's node IDs. Structurally matching nodes (same type, same tag) get their data updated. Non-matching nodes at the same position are replaced with src nodes.

Returns a map from src node IDs → dst node IDs for every position where dst's ID was kept (canMerge=true and IDs differ). Callers use this to remap bindings that reference src IDs to the IDs the merged tree has.

Call this after Diff(dst, src) to bring dst in sync with what the browser will have after patches are applied. dst is never replaced — it is the long-lived tree that persists across renders.

func ParseForExpr

func ParseForExpr(expr string) (item, index, list string)

ParseForExpr parses "todo in Todos" or "todo, i in Todos".

func ParseMapAccess

func ParseMapAccess(expr string) (field, key string, ok bool)

ParseMapAccess parses "Field[key]" into ("Field", "key", true). Returns ("", "", false) if the expression doesn't match bracket syntax.

func ParseMethodCall

func ParseMethodCall(expr string) (method string, args []string)

ParseMethodCall parses "Save", "Toggle(i)", "Remove(i, todo.ID)".

func ResolveExpr

func ResolveExpr(exprStr string, ctx *ResolveContext) any

ResolveExpr resolves an expression string against the context. Simple field/variable access uses direct reflection (fast path). Complex expressions (comparisons, operators, function calls) use expr-lang.

Types

type Binding

type Binding struct {
	NodeID int
	Kind   string // "style", "prop", "attr", "text"
	Prop   string // property/style/attr name (empty for text)
	Expr   string // original expression (e.g., "Inputs[first]") — used by g-bind to write back
}

Binding records a dependency: "field X affects node Y's property Z." Used for surgical updates — when a field changes, only the bound nodes are patched.

type Directive

type Directive struct {
	Type string // "text", "html", "bind", "value", "checked", "if", "show", "hide", "class", "attr", "style", "prop",
	// "click", "keydown", "mousedown", "mousemove", "mouseup", "wheel", "scroll", "drop",
	// "draggable", "dropzone"
	Name string // modifier name: class name, attr name, style property, key filter, etc.
	Expr string // expression: field name, method call, etc.
}

Directive represents a single g-* directive on an element.

type ElementNode

type ElementNode struct {
	NodeBase
	Tag       string // e.g. "div", "span", "path"
	Namespace string // "" for HTML, "http://www.w3.org/2000/svg" for SVG
	Facts     Facts
	Children  []Node
}

ElementNode represents an HTML or SVG element.

func (*ElementNode) AppendChild

func (n *ElementNode) AppendChild(child Node)

AppendChild adds a child node.

func (*ElementNode) NodeType

func (n *ElementNode) NodeType() int

func (*ElementNode) RemoveChild

func (n *ElementNode) RemoveChild(index int) bool

RemoveChild removes the child at the given index. Returns false if out of bounds.

func (*ElementNode) RemoveChildByID

func (n *ElementNode) RemoveChildByID(id int) bool

RemoveChildByID removes the first child with the given node ID. Returns false if not found.

func (*ElementNode) ReplaceChild

func (n *ElementNode) ReplaceChild(index int, child Node) bool

ReplaceChild replaces the child at the given index. Returns false if out of bounds.

type EventHandler

type EventHandler struct {
	Handler string // method name on Go struct
	Args    []any  // pre-resolved arguments
	Scope   string // "forGID:index" for child component routing
	Options EventOptions
}

EventHandler describes an event listener that routes to a Go method.

type EventOptions

type EventOptions struct {
	StopPropagation bool
	PreventDefault  bool
	Key             string // key filter for keydown events
}

EventOptions controls event propagation behavior.

type Facts

type Facts struct {
	Props   map[string]any          // DOM properties: id, className, value, checked, ...
	Attrs   map[string]string       // HTML attributes: data-*, aria-*, custom
	AttrsNS map[string]NSAttr       // namespaced attributes: xlink:href, xml:lang
	Styles  map[string]string       // CSS properties: background-color, width, ...
	Events  map[string]EventHandler // event listeners: click, input, keydown, ...
}

Facts holds all the "attributes" of an element, categorized by type.

type FactsDiff

type FactsDiff struct {
	Props   map[string]any           // changed/added/removed properties
	Attrs   map[string]string        // changed/added/removed attributes ("" = remove)
	AttrsNS map[string]NSAttr        // changed/added/removed namespaced attributes
	Styles  map[string]string        // changed/added/removed styles ("" = remove)
	Events  map[string]*EventHandler // changed/added events (nil = remove)
}

FactsDiff represents changes between two Facts.

func DiffFacts

func DiffFacts(old, new *Facts) FactsDiff

DiffFacts computes the difference between two Facts structs.

func (*FactsDiff) IsEmpty

func (d *FactsDiff) IsEmpty() bool

IsEmpty returns true if there are no changes.

type IDCounter

type IDCounter struct {
	Seq int
	// contains filtered or unexported fields
}

IDCounter assigns monotonically increasing node IDs. It must persist across renders (never reset) so that existing IDs in the bridge's node map remain valid. Thread-safe — shared across all components.

func (*IDCounter) Next

func (c *IDCounter) Next() int

type InputBinding

type InputBinding struct {
	Field string // struct field name (binding key)
	Expr  string // original expression (e.g., "Inputs[first]")
	Prop  string // "value" or "checked"
}

InputBinding is the reverse lookup: nodeID → field info for input nodes.

type KeyedChild

type KeyedChild struct {
	Key  string
	Node Node
}

KeyedChild pairs a stable key with a child node.

type KeyedElementNode

type KeyedElementNode struct {
	NodeBase
	Tag       string
	Namespace string
	Facts     Facts
	Children  []KeyedChild
}

KeyedElementNode is an element whose children have stable keys.

func (*KeyedElementNode) NodeType

func (n *KeyedElementNode) NodeType() int

type LazyNode

type LazyNode struct {
	NodeBase
	Func   any   // the view function
	Args   []any // arguments checked by reference equality
	Cached Node  // previously computed result (nil on first render)
}

LazyNode defers tree construction until diffing time.

func (*LazyNode) DescendantsCount

func (n *LazyNode) DescendantsCount() int

func (*LazyNode) NodeType

func (n *LazyNode) NodeType() int

type NSAttr

type NSAttr struct {
	Namespace string
	Value     string
}

NSAttr is a namespaced attribute value (used for SVG xlink/xml attributes).

type Node

type Node interface {
	NodeType() int
	NodeID() int
	DescendantsCount() int
	IsRemoved() bool
}

Node is the interface implemented by all virtual DOM node types.

func FindNodeByID

func FindNodeByID(root Node, id int) Node

FindNodeByID searches the tree for a node with the given ID.

func MergeAdjacentText

func MergeAdjacentText(nodes []Node) []Node

MergeAdjacentText collapses consecutive TextNode entries into one and removes empty TextNodes.

func ResolveTemplateNode

func ResolveTemplateNode(t *TemplateNode, ctx *ResolveContext) []Node

ResolveTemplateNode resolves a single template node into zero or more Nodes.

func ResolveTree

func ResolveTree(templates []*TemplateNode, ctx *ResolveContext) []Node

ResolveTree resolves a list of template nodes into concrete Nodes.

type NodeBase

type NodeBase struct {
	ID          int  // stable identity, assigned by Go, used to address the node in the bridge
	Descendants int  // cached count, set by ComputeDescendants
	Removed     bool // true after the node has been removed from the live tree
}

NodeBase holds fields common to all node types. Embed this in every concrete node.

func (*NodeBase) DescendantsCount

func (b *NodeBase) DescendantsCount() int

func (*NodeBase) IsRemoved

func (b *NodeBase) IsRemoved() bool

func (*NodeBase) NodeID

func (b *NodeBase) NodeID() int

type Patch

type Patch struct {
	Type   int // one of the Patch* constants
	NodeID int // ID of the target node in the OLD tree (what the bridge knows)
	Data   any // type-specific payload (see below)
}

Patch describes a single DOM mutation produced by the diff algorithm.

func Diff

func Diff(oldTree, newTree Node) []Patch

Diff computes the minimal set of patches needed to transform oldTree into newTree. Both trees must have had ComputeDescendants called on them before diffing.

type PatchAppendData

type PatchAppendData struct {
	Nodes []Node
}

PatchAppendData carries new children to append.

type PatchFactsData

type PatchFactsData struct {
	Diff FactsDiff
}

PatchFactsData carries the diff between old and new facts.

type PatchLazyData

type PatchLazyData struct {
	Patches []Patch
}

PatchLazyData wraps patches that apply inside a lazy node's cached subtree.

type PatchPluginData

type PatchPluginData struct {
	Data any
}

PatchPluginData carries new data for a plugin node.

type PatchRedrawData

type PatchRedrawData struct {
	Node Node
}

PatchRedrawData carries the new node to render from scratch.

type PatchRemoveData

type PatchRemoveData struct {
	Key     string
	Patches []Patch
}

PatchRemoveData carries info for removing a specific keyed child.

type PatchRemoveLastData

type PatchRemoveLastData struct {
	Count int
}

PatchRemoveLastData carries the number of children to remove from the end.

type PatchReorderData

type PatchReorderData struct {
	Inserts []ReorderInsert
	Removes []ReorderRemove
	Patches []Patch
}

PatchReorderData carries the keyed reorder operation.

type PatchTextData

type PatchTextData struct {
	Text string
}

PatchTextData carries the new text content.

type PluginNode

type PluginNode struct {
	NodeBase
	Tag   string // host element tag, e.g. "canvas", "div"
	Name  string // plugin name, e.g. "chart"
	Facts Facts
	Data  any // JSON-serializable data passed to JS plugin
}

PluginNode is an opaque node managed by a JS plugin.

func (*PluginNode) NodeType

func (n *PluginNode) NodeType() int

type ReorderInsert

type ReorderInsert struct {
	Index int
	Key   string
	Node  Node
}

ReorderInsert describes a node to insert at a given position.

type ReorderRemove

type ReorderRemove struct {
	Index int
	Key   string
}

ReorderRemove describes a node to remove (or move) during reorder.

type ResolveContext

type ResolveContext struct {
	State    reflect.Value        // the component struct (or pointer to it)
	Vars     map[string]any       // loop variables: {todo: item, i: index}
	IDs      *IDCounter           // node ID allocator (must persist across renders)
	Bindings map[string][]Binding // field name → bindings (built during resolve)

	// Reverse lookup: nodeID → field for input bindings (g-bind, g-value, g-checked)
	InputBindings map[int]InputBinding

	// Unbound input support
	UnboundValues map[string]any // stableKey → stored value (passed in from component.Info)
	NodeStableIDs map[int]string // nodeID → stableKey (built during resolve, read by server)
	ForIndices    []int          // current g-for loop index stack (for composite stable keys)

	// ExtraEnv holds engine-provided expression functions that are not struct
	// fields or methods — e.g. the task-state bindings Busy/Progress/Err/Crashed.
	// They are merged into the base env with lower precedence than struct
	// members (a struct field/method of the same name wins, for back-compat).
	ExtraEnv map[string]any
	// contains filtered or unexported fields
}

ResolveContext holds the state and loop variables available during tree resolution.

type TemplateNode

type TemplateNode struct {
	// For element nodes
	Tag       string
	Namespace string
	Attrs     []html.Attribute // static HTML attributes (non-directive)

	// Directives (extracted from g-* attributes)
	Directives []Directive

	// Children (for elements) or nil (for text)
	Children []*TemplateNode

	// For text nodes
	IsText    bool
	TextParts []TextPart // static text + {expr} interpolations

	// For g-for nodes
	IsFor    bool
	ForItem  string          // loop variable name, e.g. "todo"
	ForIndex string          // index variable name, e.g. "i" (empty if unused)
	ForList  string          // list field, e.g. "Todos"
	ForKey   string          // key expression, e.g. "todo.ID" (empty = positional)
	ForBody  []*TemplateNode // template for each item

	// For plugin nodes
	IsPlugin   bool
	PluginName string // plugin name from g-plugin:name
	PluginExpr string // data expression

	// StableID is a UUID assigned at parse time to unbound form inputs.
	// Used to preserve input values across tree rebuilds.
	StableID string
}

TemplateNode represents one node in the parsed template tree.

func ParseTemplate

func ParseTemplate(htmlStr string) ([]*TemplateNode, error)

ParseTemplate parses HTML into a template tree.

type TextNode

type TextNode struct {
	NodeBase
	Text string
}

TextNode is a leaf node containing plain text.

func (*TextNode) NodeType

func (n *TextNode) NodeType() int

type TextPart

type TextPart struct {
	Static bool
	Value  string // literal text if Static, expression string if not
}

TextPart represents a segment of text content.

func ParseTextInterpolations

func ParseTextInterpolations(text string) []TextPart

ParseTextInterpolations splits text containing {{expr}} into parts.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL