Documentation
¶
Overview ¶
Package jit provides Just-In-Time optimisation strategies for Fluent HTML rendering. Each strategy targets a different performance profile. The base Fluent API already performs well with automatic buffer pooling; JIT is for squeezing out extra performance in high-throughput scenarios.
Choosing a Strategy ¶
Start by asking what your content looks like and what you need back.
Is the content fully static (no Text, Func, Condition)?
Yes -> Flatten. Pre-renders once to []byte. Subsequent calls are
a memory copy. Use for headers, footers, navigation, any
content built entirely from Static() calls.
Does the content mix static and dynamic parts?
Yes, and I just need fast renders -> Compile. Freezes static
portions on first render, re-evaluates only dynamic nodes
via path-based navigation. Best general-purpose strategy.
Yes, but the tree structure changes between renders -> Tune.
Adaptive buffer sizing without structural assumptions.
No compilation step, no frozen plan. Works with any content
that changes shape over time.
Do I need to know what changed between renders (patches)?
Yes, and rebuilding the tree is cheap -> Differ. You rebuild the
full tree on each state change. The Differ walks both trees,
compares keyed elements, and returns Patch values for only
the elements whose HTML actually changed.
Yes, but some subtrees are expensive to rebuild -> Memoiser.
Wraps expensive subtrees in node.Memoise with a cache key.
When the key matches the previous render, the closure never
runs. Only changed subtrees produce new HTML for diffing.
Differ vs Memoiser ¶
These two share the same interface (Render, Diff, DiffKey, Export, Import) and produce the same output ([]Patch, *StructuralChange). They differ in how they decide whether content changed.
The Differ is content-based. It renders every keyed element on every Diff call and compares the HTML bytes against stored snapshots. If the bytes match, no patch is emitted. This is simple and correct but means every closure runs on every diff, even if nothing changed.
differ := jit.NewDiffer() html := differ.Render(buildTree(state)) // initial render patches, change := differ.Diff(buildTree(newState)) // full re-render + compare
The Memoiser is key-based. Each Dynamic region wraps its content in node.Memoise with a cache key (typically a version counter, hash, or timestamp). When the key matches, the closure is skipped entirely - no HTML is produced, no comparison is needed. When the key differs, the closure runs and the result is compared against the snapshot.
memoiser := jit.NewMemoiser() html := memoiser.Render(buildTree(state)) // initial render patches, change := memoiser.Diff(buildTree(newState)) // skips unchanged closures
Use Differ when:
- Rebuilding the tree is cheap (simple templates, small data)
- You don't have natural cache keys for your data
- You want the simplest mental model (rebuild everything, diff finds changes)
Use Memoiser when:
- Some subtrees are expensive (database queries, complex formatting)
- You have natural version identifiers (timestamps, counters, hashes)
- You want to avoid running closures for unchanged regions
- You have many keyed elements but few change on each update
Both support DiffKey for targeted single-key diffs when you know exactly which element changed. DiffKey is over 1,000x faster than a full Diff for targeting one key out of many.
Use one or the other per session, not both. They maintain independent snapshot state and mixing them produces incorrect diffs.
Keying Nodes for Tracking ¶
The diff engine tracks any node that satisfies node.Dynamic and returns a non-empty key from DynamicKey(). Elements use .Dynamic("key") to set this. Function and conditional nodes support the same method:
// Element - tracked as "counter"
span.Textf("Count: %d", n).Dynamic("counter")
// Function - tracked as "greeting"
node.Func(func() node.Node {
return div.Text(greetUser())
}).Dynamic("greeting")
// Conditional - tracked as "auth"
node.When(loggedIn, welcomePanel).Dynamic("auth")
// Multi-node function - tracked as "items"
node.Funcs(func() []node.Node {
return buildItems(data)
}).Dynamic("items")
Without a key, the differ cannot produce targeted patches for that node. Keys must be unique within a render tree.
Combining Strategies ¶
Strategies operate at different levels and can be combined:
Compile + Differ: Compile handles the render path (freezing static content), Differ handles the diff path (tracking changes). Use Compile for the initial full render, Differ for subsequent updates.
Flatten for static regions, Compile for dynamic templates: Different parts of the page can use different strategies. A static nav bar uses Flatten; the main content area uses Compile.
Instance API vs Global API ¶
Each strategy has two ways to use it:
Instance API: Create with NewCompiler, NewTuner, NewFlattener, NewDiffer, or NewMemoiser. You own the lifecycle. Use when you need per-session or per-component control.
Global API: Package-level Compile, Tune, and Flatten functions with string IDs. Managed in a global registry. Convenient for application-wide templates with fixed IDs. The registry grows indefinitely - use constant IDs, not user-derived strings.
The Differ and Memoiser are instance-only. They track per-session state (snapshots, keys, ordering) that doesn't suit a global registry.
Index ¶
- Variables
- func Compile(id string, n node.Node, w ...io.Writer) []byte
- func CompileConfig(id string, cfg CompilerCfg)
- func Flatten(id string, n node.Node, w ...io.Writer) []byte
- func ResetCompile(ids ...string)
- func ResetFlatten(ids ...string)
- func ResetTune(ids ...string)
- func Tune(id string, n node.Node, w ...io.Writer) []byte
- func TuneConfig(id string, cfg TunerCfg)
- type AdaptiveSizer
- type CompiledElement
- type Compiler
- type CompilerCfg
- type Differ
- func (d *Differ) Clear()
- func (d *Differ) Diff(root node.Node) ([]Patch, *StructuralChange)
- func (d *Differ) DiffKey(key string, subtree node.Node) *Patch
- func (d *Differ) Export() []byte
- func (d *Differ) Import(data []byte) error
- func (d *Differ) Render(root node.Node, w ...io.Writer) []byte
- func (d *Differ) Validate(root node.Node) error
- type DynamicPath
- type ExecutionPlan
- type Flattener
- type Memoiser
- func (m *Memoiser) Clear()
- func (m *Memoiser) Diff(root node.Node) ([]Patch, *StructuralChange)
- func (m *Memoiser) DiffKey(key string, subtree node.Node) *Patch
- func (m *Memoiser) Export() []byte
- func (m *Memoiser) Import(data []byte) error
- func (m *Memoiser) Render(root node.Node, w ...io.Writer) []byte
- func (m *Memoiser) Stats() (hits, misses int)
- func (m *Memoiser) Validate(root node.Node) error
- type Patch
- type StaticContent
- type StructuralChange
- type Tuner
- type TunerCfg
Constants ¶
This section is empty.
Variables ¶
var ErrDuplicateKey = fmt.Errorf("duplicate dynamic key in render tree")
ErrDuplicateKey is returned when a render tree contains duplicate dynamic keys. Keys must be unique within a tree so the diff engine can unambiguously track each dynamic element across renders.
var ErrDynamicContent = errors.New("NewFlattener() requires static content - use NewCompiler() for dynamic content")
ErrDynamicContent is returned when attempting to flatten dynamic content. The flattener can only cache static content - dynamic nodes must be re-evaluated on each render, which defeats the purpose of flattening.
var ErrStructureMismatch = errors.New("node tree structure does not match the compiled execution plan")
ErrStructureMismatch indicates that a node tree passed to Compiler.Validate has a different structure than the tree used to build the execution plan. The compiler navigates dynamic nodes by their position in the tree (stored as index paths), so a structural change means those paths no longer resolve to the correct nodes - producing truncated or incorrect output.
var SnapshotHint = 128
SnapshotHint is the initial capacity hint in bytes for snapshot buffers. Most keyed elements render to small HTML fragments, so 128 bytes avoids an early grow in the common case. Adjust if your elements are typically larger or smaller.
Functions ¶
func Compile ¶
Compile looks up a compiler by ID in a global registry, creating it if it doesn't exist, and renders it using the compilation strategy. If CompileConfig() was called first, that config will be used.
The node is used both to build the plan (on first call) and to provide dynamic content for rendering. Static content is frozen from the first call.
Warning: The global registry grows indefinitely. Do not use dynamic IDs without manually calling ResetCompile(id) to free memory.
func CompileConfig ¶
func CompileConfig(id string, cfg CompilerCfg)
CompileConfig creates a compiler instance with custom configuration. Must be called before first Compile() call for the given ID.
func Flatten ¶
Flatten looks up flattened static content in the global registry. On first call with a node, it validates the content is static, renders it once, and stores the result. Subsequent calls retrieve the stored bytes.
Unlike NewFlattener which returns an error for dynamic content, this silently falls back to uncached rendering. This avoids disrupting request handlers where returning an error would be impractical.
Warning: The global registry grows indefinitely. Do not use dynamic IDs without manually calling ResetFlatten(id) to free memory.
func ResetCompile ¶
func ResetCompile(ids ...string)
ResetCompile removes compiled templates from the global registry, allowing them to be re-compiled on next use. Call with no arguments to clear all entries, or pass specific IDs to remove.
func ResetFlatten ¶
func ResetFlatten(ids ...string)
ResetFlatten removes flattened static content from the global registry. Call with no arguments to clear all entries, or pass specific IDs to remove.
func ResetTune ¶
func ResetTune(ids ...string)
ResetTune removes tuned templates from the global registry, causing their tuning statistics to be reset on next use. Call with no arguments to clear all entries, or pass specific IDs to remove.
func Tune ¶
Tune looks up a tuner by ID in a global registry, creating it if it doesn't exist, and renders it using the adaptive tuning strategy. If TuneConfig() was called first, that config will be used.
Warning: The global registry grows indefinitely. Do not use dynamic IDs without manually calling ResetTune(id) to free memory.
func TuneConfig ¶
TuneConfig creates a tuner instance with custom configuration. Must be called before first Tune() call for the given ID.
Types ¶
type AdaptiveSizer ¶
type AdaptiveSizer struct {
// contains filtered or unexported fields
}
AdaptiveSizer implements adaptive buffer sizing with minimal lock contention. It operates in two phases:
1. Sampling Phase: Collects render size samples to establish optimal buffer size. 2. Baseline Phase: Uses established size with variance monitoring for pattern changes.
Performance characteristics: - Hot path (GetBaseline): lock-free atomic read - called on every render. - Warm path (variance checks): occasional mutex for pattern change detection. - Cold path (sampling): mutex for statistical calculations during startup.
func NewAdaptiveSizer ¶
func NewAdaptiveSizer() *AdaptiveSizer
NewAdaptiveSizer creates a sizer with sensible defaults. Default configuration: - max: 5 samples (quick baseline establishment). - variance: 20% (detects significant size changes). - growthFactor: 115% (prevents buffer resizing on small variations). - active: true (starts in sampling phase).
func (*AdaptiveSizer) Active ¶
func (as *AdaptiveSizer) Active() bool
Active returns true if currently in sampling phase. Lock-free read for performance.
func (*AdaptiveSizer) Configure ¶
func (as *AdaptiveSizer) Configure(max int, variance, growthFactor int)
Configure sets custom parameters and resets all statistics. This forces the sizer to restart sampling with new parameters, because stale statistics from previous configuration would produce an incorrect baseline.
Parameters: - max: number of samples to collect before establishing baseline. - variance: threshold percentage for detecting significant size changes (e.g. 20). - growthFactor: multiplier percentage applied to average size (e.g. 115).
func (*AdaptiveSizer) GetBaseline ¶
func (as *AdaptiveSizer) GetBaseline() int
GetBaseline returns the current optimal buffer size. This is the hot path - called on every render - so it uses a lock-free atomic read to avoid contention.
func (*AdaptiveSizer) Reset ¶
func (as *AdaptiveSizer) Reset()
Reset clears all statistics and restarts sampling. Useful when content patterns change significantly.
func (*AdaptiveSizer) UpdateStats ¶
func (as *AdaptiveSizer) UpdateStats(size int)
UpdateStats updates sizing statistics based on actual render size. This automatically chooses between sampling and variance checking based on the current phase.
type CompiledElement ¶
CompiledElement represents a single rendering operation in the execution plan. Elements are either pre-rendered static content or dynamic node references.
type Compiler ¶
type Compiler struct {
// contains filtered or unexported fields
}
Compiler builds immutable execution plans with optimised buffer sizing. It separates static and dynamic content during compilation, then uses conditional statistical updates to maintain optimal buffer allocation.
func NewCompiler ¶
func NewCompiler(cfg ...*CompilerCfg) *Compiler
NewCompiler creates a compiler with sensible defaults. Default threshold: 15% deviation before updating buffer size statistics.
func (*Compiler) Configure ¶
Configure customises the compiler's threshold and adaptive sizing parameters. Returns the same instance for method chaining.
func (*Compiler) Render ¶
Render builds the execution plan on first call, then renders the node. Subsequent calls reuse the existing plan with fresh dynamic content from the provided tree.
Static content (including attributes) is frozen from the first call. Dynamic content is re-evaluated from the provided tree on each call.
Example:
compiler := jit.NewCompiler()
compiler.Render(UserCard("Alice", 30), w) // builds plan + renders Alice
compiler.Render(UserCard("Bob", 25), w) // reuses plan, renders Bob
compiler.Render(UserCard("Dan", 40), w) // reuses plan, renders Dan
func (*Compiler) Validate ¶ added in v0.1.2
Validate checks whether a node tree is structurally compatible with the compiled execution plan. It walks each DynamicPath in the plan and verifies that the path resolves to a valid node in the provided tree.
This is a diagnostic tool for tests and development - it should NOT be called in production because it adds overhead to every render. In production, a structure mismatch will produce visibly broken output, which is sufficient signal to investigate.
Returns nil if the tree is compatible, or ErrStructureMismatch with details about which path failed.
Example (in a test):
compiler := jit.NewCompiler()
compiler.Render(baseTree) // builds plan
if err := compiler.Validate(newTree); err != nil {
t.Fatalf("tree structure changed: %v", err)
}
type CompilerCfg ¶
type CompilerCfg struct {
Threshold int // deviation threshold percentage for conditional stats updates
Max int // samples before establishing baseline
Variance int // threshold percentage for detecting size changes
GrowthFactor int // multiplier percentage for average size
}
CompilerCfg holds configuration for JIT compiler instances.
type Differ ¶ added in v0.2.0
type Differ struct {
// contains filtered or unexported fields
}
Differ tracks rendered output of keyed dynamic nodes across renders and produces targeted patches when their content changes.
Each session should own its own Differ - they are not shared across sessions. The typical lifecycle is:
- Render() on initial page load - returns full HTML, stores snapshots
- Diff() after each state change - returns patches for changed elements
- If Diff returns a *StructuralChange, call Render() again for a full re-render
Snapshot data can be serialised for external storage via Differ.Export, restored with Differ.Import, and freed with Differ.Clear. This supports offloading disconnected session data to reduce memory usage.
func (*Differ) Clear ¶ added in v0.2.1
func (d *Differ) Clear()
Clear releases the differ's snapshot buffers back to the pool and resets it to an unseeded state. Call this after a successful DiffStore.Save to free the memory that Export copied out.
func (*Differ) Diff ¶ added in v0.2.0
func (d *Differ) Diff(root node.Node) ([]Patch, *StructuralChange)
Diff compares the new tree against stored snapshots and returns targeted patches for any keyed dynamic nodes whose content changed.
Returns (patches, nil) when all keys match between renders. The patches slice is nil if nothing changed.
Returns (nil, *StructuralChange) when keys were added, removed, or reordered - the caller should use Render for a full re-render and can use the StructuralChange for diagnostics.
Returns (nil, nil) if Render has not been called yet.
func (*Differ) DiffKey ¶ added in v0.2.3
DiffKey re-renders a single Dynamic key against the stored snapshot and returns a patch if the content changed. Use this for targeted updates where the caller knows exactly which key changed and wants to avoid the cost of a full tree walk via Differ.Diff. For a page with 50 Dynamic keys, DiffKey is over 1,000x faster than Diff.
The snapshot for the targeted key is updated so subsequent Diff calls see the new content. Other keys are not touched.
Returns nil if the content is unchanged. Returns a patch with the new HTML if the content changed or the key has no stored snapshot.
func (*Differ) Export ¶ added in v0.2.1
Export returns the differ's snapshot data as raw bytes suitable for external storage. The differ's internal state is unchanged - call Clear to release the memory after a successful save. Returns nil if the differ has not been seeded.
The encoding is an internal detail. Callers must not interpret the bytes - use Import to restore them.
func (*Differ) Import ¶ added in v0.2.1
Import restores snapshot data from a prior Export call. The differ is marked as seeded after a successful import, allowing Diff to compare against the restored snapshots.
func (*Differ) Render ¶ added in v0.2.0
Render produces the full HTML for the tree and stores snapshots of all keyed dynamic nodes. Use this for the initial page load and after structural changes detected by Diff.
If a writer is provided, the HTML is written to it and nil is returned. If no writer is provided, the HTML is returned as a byte slice.
type DynamicPath ¶
type DynamicPath struct {
Path []int // Indices to navigate: e.g., [0, 1] means root.Nodes()[0].Nodes()[1]
}
DynamicPath holds the path to a dynamic node in the tree structure. The path is a slice of indices that navigates from root to the dynamic node. This enables re-evaluation with new tree instances that share the same structure.
type ExecutionPlan ¶
type ExecutionPlan struct {
Elements []CompiledElement // Linear sequence of rendering operations
}
ExecutionPlan contains the compiled sequence of static and dynamic elements. The plan is a linear sequence that can be executed without tree traversal.
type Flattener ¶
type Flattener struct {
// contains filtered or unexported fields
}
Flattener holds pre-rendered static content as bytes. This is the instance API for static content rendering - no map lookups, just direct byte access. Ideal for maximum performance with static templates.
func NewFlattener ¶
NewFlattener creates a flattener by rendering static content once. Returns an error if the node contains dynamic content.
type Memoiser ¶ added in v0.2.3
type Memoiser struct {
// contains filtered or unexported fields
}
Memoiser provides an alternative to Differ for render trees that use node.Memoise nodes. It is a standalone concern - use either the Differ or the Memoiser, not both on the same session.
When the developer opts into memoisation, every Dynamic region in the render tree should contain a node.Memoise child with a cache key. On each Diff call, the Memoiser compares memoisation keys with the previous render. Matching keys skip the subtree entirely - the closure never runs and no HTML is rendered. Mismatched keys call the closure and render the result into a snapshot for comparison.
The Memoiser does not fall back to content-based diffing for non-memoised Dynamic nodes. If a Dynamic node has no memoised child, it is always re-rendered (treated as a miss). This keeps the implementation simple and fast - there is no tree walking via Nodes() that would materialise closures.
func NewMemoiser ¶ added in v0.2.3
func NewMemoiser() *Memoiser
NewMemoiser creates an empty Memoiser ready for use.
func (*Memoiser) Clear ¶ added in v0.2.3
func (m *Memoiser) Clear()
Clear releases snapshot buffers and resets all state.
func (*Memoiser) Diff ¶ added in v0.2.3
func (m *Memoiser) Diff(root node.Node) ([]Patch, *StructuralChange)
Diff compares the new tree against stored snapshots using memoisation keys. For each Dynamic node:
- If its child satisfies node.Memoiser and the key matches the previous render, the subtree is skipped. No closure is called, no HTML is rendered. The previous snapshot is reused.
- If the key differs (or there is no memoised child), node.Memoiser.MemoiseRender is called (or the node is rendered directly) and the result is compared against the stored snapshot.
Returns (patches, nil) when Dynamic keys match between renders. Returns (nil, *StructuralChange) when keys were added, removed, or reordered. Returns (nil, nil) if Render has not been called.
func (*Memoiser) DiffKey ¶ added in v0.2.3
DiffKey re-renders a single Dynamic key against the stored snapshot and returns a patch if the content changed. Use this for targeted updates where the caller knows exactly which key changed. Does not check memoisation keys because the developer is explicitly targeting this key. The snapshot is updated so subsequent Diff calls see the new content.
func (*Memoiser) Export ¶ added in v0.2.3
Export returns the Memoiser's snapshot and memoisation key data as raw bytes suitable for external storage. Returns nil if unseeded.
func (*Memoiser) Import ¶ added in v0.2.3
Import restores snapshot and memoisation key data from a prior Export.
func (*Memoiser) Render ¶ added in v0.2.3
Render produces the full HTML for the tree and stores snapshots and memoisation keys for all Dynamic regions. Use this for the initial page load and after structural changes detected by Diff.
func (*Memoiser) Stats ¶ added in v0.2.3
Stats returns the hit and miss counts from the most recent Diff call. A hit means the memoisation key matched and the subtree was skipped. A miss means the key differed (or was absent) and the subtree was re-rendered. Call this immediately after Diff to inspect cache performance.
type Patch ¶ added in v0.2.0
Patch represents a targeted change to a dynamic element in the rendered output. Key matches the value passed to .Dynamic("key") on the element. HTML is the new rendered content for that element.
type StaticContent ¶
type StaticContent struct {
Content []byte // Pre-rendered HTML bytes ready for direct buffer writes
}
StaticContent holds pre-rendered static HTML content as raw bytes. Adjacent static nodes are merged into single StaticContent elements for efficiency.
type StructuralChange ¶ added in v0.2.0
type StructuralChange struct {
Added []string // keys present in the new tree but not the old
Removed []string // keys present in the old tree but not the new
Reordered bool // same keys, different order
}
StructuralChange describes why a diff detected a structural change. The caller can use this to produce actionable diagnostics that tell the developer exactly what changed and how to avoid root morphs.
func (*StructuralChange) String ¶ added in v0.2.0
func (c *StructuralChange) String() string
String returns a human-readable description of the change, e.g. "key 'sidebar' added" or "keys reordered".
type Tuner ¶
type Tuner struct {
// contains filtered or unexported fields
}
Tuner provides dynamic adaptive buffer sizing for changing content patterns. Unlike the compiler which pre-optimises static content, the tuner adapts to content that changes over time by continuously monitoring render sizes.
The tuner uses shared AdaptiveSizer logic with two-phase operation: 1. Sampling phase: Collects render size samples to establish optimal buffer size 2. Baseline phase: Uses established size with variance monitoring for pattern changes
This approach is ideal for templates with dynamic content that varies significantly.
func NewTuner ¶
NewTuner creates a tuner with adaptive sizing defaults. Uses shared AdaptiveSizer with standard configuration: - 5 samples for baseline establishment. - 20% variance threshold for pattern change detection. - 115% growth factor to prevent tight buffer fits.
func (*Tuner) Configure ¶
Configure customises the adaptive sizing parameters and resets statistics. This forces the tuner to restart sampling with new parameters.
Parameters: - max: number of samples to collect before establishing baseline. - variance: threshold percentage for detecting significant size changes (e.g. 20). - growthFactor: multiplier percentage applied to average size (e.g. 115).
func (*Tuner) Render ¶
Render executes the configured template with adaptive buffer sizing. This method automatically optimises buffer allocation based on historical render sizes and continuously updates statistics for future optimisation.