render

package
v0.2.5 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package render turns state into whole HTML fragments.

A patch carries the complete rendered markup of each changed fragment. There is no server-side diff of consecutive renders and no compile-time decomposition of templates into static and dynamic parts: the diff that matters already happens on the client, against the live DOM, and a server-side copy of what the server believes the browser shows is a second source of truth that can drift. The developer's lever on patch size is the granularity at which fragments are declared, which is legible in the template rather than dependent on whether a template happened to introduce a local variable.

Identity and dirty tracking

Fragments are registered with stable identifiers; a duplicate registration is an error naming both call sites, never a silent last-write-wins. A transition may declare which fragments it touched. Over-declaring is safe, because a render whose bytes are unchanged is dropped by the suppression below; under-declaring is a correctness bug, and livetest.AssertDirtyComplete is what catches it before it reaches production.

Suppression

This package retains a 64-bit hash of each fragment's last emitted bytes — the hash, not the bytes, because retaining previous renders would cost more per session than the whole memory budget allows. A re-render producing the same hash emits nothing, but still advances the transition counter, so the provenance record shows that the transition happened and produced no patch.

Determinism

The same state must render byte-identical HTML, across runs and across processes. The known hazard is ranging over a Go map in a template: range a sorted slice instead. A repeated-render byte-equality test enforces it.

Status

Implemented: the registry, per-session dirty tracking, identical-render suppression, and panic containment at both the render and change-declaration sites.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type DirtyFunc

type DirtyFunc func(prev, next any) bool

DirtyFunc reports whether a transition may have changed a fragment. A nil DirtyFunc means "re-render on every transition", which is always safe: over-declaring costs a suppressed render, under-declaring costs a stale fragment in production.

type Failure

type Failure struct {
	// FragmentID is the region whose application code panicked, and therefore
	// the one region this transition leaves stale.
	FragmentID string

	// Site is "render" or "dirty". Both are application code on the render
	// path and both are counted against the render panic budget; the
	// distinction is for the log record, where it saves a bisect.
	Site string

	// Value is whatever was passed to panic, unconverted. It reaches the
	// browser only in dev: in production the Error frame carries a fixed
	// generic message, because a panic value is server internals.
	Value any

	// Stack is the stack captured at recovery, for the log record. It is never
	// sent to a client in either mode.
	Stack []byte
}

Failure reports a panic recovered while deciding or producing one fragment's markup.

A render that panics leaves that fragment stale and lets every other fragment in the same transition patch normally. It does not trigger a resync: a render that panics will panic again, and a resync loop is worse than one stale region.

type Fragment

type Fragment struct {
	// ID is the fragment's stable identity. It must match the schema's
	// pattern and be unique within an application.
	ID string
	// Render writes the fragment's markup. It must be a pure function of
	// state: same state, byte-identical output, across runs and processes.
	Render RenderFunc
	// Dirty is the optional change declaration.
	Dirty DirtyFunc

	// Children projects ordered nested regions from session state.
	Children func(state any) []Fragment
}

Fragment declares one server-owned live region.

type FragmentObserver

type FragmentObserver func(ctx context.Context, fragmentID string) (context.Context, func(suppressed, failed bool))

FragmentObserver is called around one fragment's render. It returns the context that fragment renders under and a function that closes the observation, told whether the markup was suppressed as identical and whether the render failed.

It is a function value rather than an interface for the reason WriteFunc is: there is one implementation, and a one-implementation interface buys nothing (checklist §1.4). It is also what keeps this package out of the observability package's import graph — an architecture test forbids the render path from reaching a clock, a logger, or the outside world, and internal/obs imports log/slog and time, so the actor passes a closure in rather than the renderer reaching a tracer out.

A nil observer is the disabled configuration and costs one branch per fragment.

type Op

type Op int

Op is how a fragment update is applied to the DOM. It mirrors the wire enumeration without naming it: the render path is a pure function of state and does not depend on the protocol package, which is what keeps its import allowlist meaningful.

const (
	OpUnspecified Op = iota
	OpMorph
	OpAppend
	OpPrepend
	OpRemove
)

The operations. OpUnspecified is the zero value and is never emitted; it exists so that a fragment that forgot to name an operation fails at the outbound boundary rather than arriving as a silent morph.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is an application's fragment set. It is immutable after construction and shared by every session, which is why it holds no per-session state: the render hashes live in a Renderer.

func NewRegistry

func NewRegistry(frags []Fragment) (*Registry, error)

NewRegistry validates a fragment set and returns it.

A duplicate identifier is an error naming both declarations rather than a silent last-write-wins, and it is reported at construction so it fails at startup instead of at the first patch that goes to the wrong region.

func (*Registry) AdmitsID

func (r *Registry) AdmitsID(id string) bool

AdmitsID is the immutable ingress check. A dynamic child passes only its declared namespace here; the actor checks exact committed membership before dispatch, after any in-progress send and membership publication finish.

func (*Registry) IDs

func (r *Registry) IDs() []string

IDs returns the declared fragment identifiers, in declaration order.

func (*Registry) Index

func (r *Registry) Index(id string) (int, bool)

Index returns a fragment's position, and whether it is declared at all. An event naming an undeclared fragment is refused rather than dispatched.

func (*Registry) Len

func (r *Registry) Len() int

Len reports how many fragments the application declares.

func (*Registry) NewRenderer

func (r *Registry) NewRenderer() *Renderer

NewRenderer returns a per-session renderer over r. Every fragment starts dirty, because a session that has emitted nothing has nothing to suppress against.

type RenderFunc

type RenderFunc func(ctx context.Context, state any, w io.Writer) error

RenderFunc writes one fragment's markup for a state value.

It takes any rather than a type parameter because the session actor holds application state as an opaque value; the public package closes over the type and hands this package a function that has already asserted it.

The writer is valid only for the duration of the call

w is a handle onto per-session storage that is reused for every fragment of every pass, and the renderer reads what was written the moment this function returns. Writing to it afterwards — from a goroutine the render started, or from a handle a later call retained — is refused with an error rather than silently landing in another fragment's markup. Nothing about w may be retained, and the underlying buffer is deliberately unreachable: w is not a *bytes.Buffer and a type assertion to one fails, because .Bytes() would hand out a live view of storage the next fragment overwrites (U-6).

type Renderer

type Renderer struct {
	// contains filtered or unexported fields
}

Renderer is one session's view of a registry: the dirty set and the hash of each fragment's last emitted bytes.

It holds the hash and not the bytes. Retaining the previous render per fragment per session would cost more than the whole per-connection memory budget, to save bytes on a link the client is already diffing against its own DOM.

func (*Renderer) Commit

func (v *Renderer) Commit(res Result)

Commit installs the hashes a pass computed, and is the ONLY thing that may write v.hashes.

The caller calls it after — never before — the pass's markup reached the transport. hashes[i] means "the bytes the client holds for fragment i", and suppression is only sound while that is true: a hash installed by the render pass itself is installed before the send that can still fail, and a send that fails survivably (protocol.InvalidFrameError, which deliberately keeps the session alive) then leaves the renderer believing the client holds markup that was never written to the socket. Every later render of that fragment producing the same bytes is suppressed, so the region is stale for the life of the connection with nothing saying so.

func (*Renderer) Discard

func (v *Renderer) Discard(res Result)

Discard reverses a pass whose markup never reached the transport: the fragments it updated go back into the dirty set so the next pass renders them again, and their hashes are never installed, so the retry cannot be suppressed as identical.

The fragments that panicked are deliberately NOT re-marked. A render that panics will panic again, and re-marking would convert one stale region into a session that closes on its panic budget — the same reasoning render() applies when it clears their bit in the first place.

func (*Renderer) KnownID

func (v *Renderer) KnownID(id string) bool

KnownID is called on the session actor, after pending sends and their commits finish. Ingress only admits static IDs or declared child namespaces; this exact check occurs before a browser event reaches application reduction.

func (*Renderer) Mark

func (v *Renderer) Mark(prev, next any) []Failure

Mark consults each fragment's change declaration for a transition and marks the fragments it names.

A fragment with no declaration is always marked: over-declaring is safe because an identical render is suppressed, and under-declaring is a correctness bug the determinism helpers catch before it reaches production. A declaration that panics is treated as "dirty" and reported, because the safe reading of "I do not know whether this changed" is that it did.

func (*Renderer) MarkAll

func (v *Renderer) MarkAll()

MarkAll marks every fragment dirty.

func (*Renderer) MarkID

func (v *Renderer) MarkID(id string) bool

MarkID marks one fragment dirty and reports whether it is declared.

func (*Renderer) Observe

func (v *Renderer) Observe(fn FragmentObserver)

Observe installs the per-fragment observer. It is called once, when the session is constructed, and never from the render path.

func (*Renderer) Pending

func (v *Renderer) Pending() bool

Pending reports whether any fragment is waiting to be rendered. It is what lets the actor keep reducing while the outbound window is full and render once, from current state, when an acknowledgement re-opens it.

func (*Renderer) Render

func (v *Renderer) Render(ctx context.Context, state any) Result

Render produces the markup for every dirty fragment, dropping the ones whose bytes are unchanged since they were last emitted.

The dirty set is cleared for every fragment this pass considered, including the suppressed ones, which are up to date by definition, and including the ones that panicked: retrying a render that panics on every transition converts one stale region into a session that closes on its panic budget.

func (*Renderer) RenderAll

func (v *Renderer) RenderAll(ctx context.Context, state any) Result

RenderAll produces the markup for every fragment regardless of the dirty set and regardless of suppression, which is what a snapshot needs: the client has nothing to morph against, so an unchanged fragment must still be sent.

type Result

type Result struct {
	// Updates carries the fragments whose markup changed.
	Updates []Update
	// Suppressed names the fragments that re-rendered to the bytes they last
	// emitted. A suppressed render is not a no-op transition: the transition
	// still happened and still gets a provenance record.
	Suppressed []string
	// Failed carries the recovered panics.
	Failed []Failure
	// contains filtered or unexported fields
}

Result is what one render pass produced.

A pass is not a commit. The hashes it computed are held here until the caller says the markup reached the transport, because a hash installed before the send is a claim about the client's DOM that the send is still free to falsify: see Commit and Discard.

type Update

type Update struct {
	// FragmentID is the region the markup belongs to, unique within an
	// application.
	FragmentID string

	// Op is how the client applies it. OpUnspecified never reaches here: the
	// outbound boundary refuses it rather than letting it arrive as a silent
	// morph.
	Op Op

	// HTML is the fragment's complete rendered markup, not a diff. The diff
	// happens in the browser, against the live DOM.
	HTML string
}

Update is the new markup for one live region.

Jump to

Keyboard shortcuts

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