All notable changes to LiveTemplate will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Async[S, R](ctx, work, apply) — run expensive work off the event loop
with type-safe on-loop state application. The generic function spawns a
goroutine for work, then dispatches apply back on the session's event
loop when it completes. This replaces the manual two-action pattern
(action triggers goroutine → goroutine calls DispatchChan → second action
applies result) with a single call that handles goroutine lifecycle,
panic recovery, and error propagation.
{{.lvt.Pending}} — framework-provided template variable for
zero-boilerplate loading indicators. On the render that registers Async
work, .lvt.Pending is true; on all other renders it is false. This
eliminates the need for a manual Loading bool field in state and the
s.Loading = true / s.Loading = false bookkeeping across two actions.
Validate(templateText) reports template problems as structured
diagnostics — including the parse and composition errors the live-render path
silently swallows. Execute/ExecuteUpdates catch a first-render failure
and fall back to an HTML-structure tree, so a malformed template (an unclosed
{{range}}, an unknown function, an unresolved {{template}}) renders
degraded with no error returned — a tool that wants to reject a bad template
before serving had nothing to call. Validate(templateText string, opts ...ValidateOption) ([]Diagnostic, error) parses the text through the real
framework function set and any component templates supplied via
WithValidateComponents — the same ParseFS path serve uses, so component
definitions resolve rather than false-positive — and returns a
Diagnostic{Line, Severity, Message} per problem (at most one today — the
parser stops at the first error). The returned
error is reserved for infrastructure failures (a component set that itself
fails to parse); a template that does not parse is always a diagnostic, not an
error, mirroring the shape of a linter. Because unknown functions are checked
against the framework's own builtins — which a downstream consumer cannot
enumerate — this check cannot be reproduced outside the module. Data-dependent
checks (render behaviour against a sample value) are out of scope for now, and
SeverityWarning is reserved for them.ClientVersion — the
constant ClientScriptURL is built from, and therefore what every app using
the documented <script src="{{ .ClientScriptURL }}"> integration loads —
still pointed at client 0.18.2 while the client had shipped 0.19.1 and 0.20.0.
0.18.2 predates the opt-in change to upload form fields, so an application on
the documented path was still serializing its entire enclosing form into every
Proxied upload (everything except type="password"), including CSRF tokens and
hidden secrets. Pinned to 0.20.0. Applications that self-host or pin the client
themselves were unaffected; those following the default were not.
(client#150, #452)lvt-upload-with is documented where the docs site actually reads from.
The upload reference and the client-attribute tables live here and are mirrored
into the docs site on release; the opt-in marking contract had been written
into the mirror instead, where the next sync would have deleted it. The
UploadStreamer godoc also still described the pre-opt-in behaviour, which is
the copy an OnUpload implementer is likeliest to read. (#452, #508)livetemplate.New() walks the template directory, and any
error from that walk aborted it — including a transient directory being removed
by something else at that moment, which surfaced as
template auto-discovery failed: readdirent …: no such file or directory from
an unrelated part of the app. A path vanishing underneath the walk is now
skipped rather than fatal. Deliberately narrow: a missing base directory
still errors, since that means the caller pointed at somewhere that does not
exist, and non-ENOENT failures such as permissions still surface. .uploads is
also skipped outright, being uploaded files rather than templates. (#502){{if}}{{if}}<li data-key="…"> puts the keyed element two levels below the
range item; key lookup only looked one level down and fell back to hashing the
item's content. Because a content hash changes when the content does, editing
such an item changed its key and the client removed and re-inserted the row
instead of patching it — the churn data-key exists to avoid. Lookup now
descends through nested wrappers, bounded at four levels. Items with no key at
any depth still use content hashes, as before. (#505)No behaviour change from these, but they are where the two fixes above came from:
FlattenTemplate now hands recursion defines back to its caller rather than
appending them itself (#503), and wrappers carry an explicit kind instead of
being inferred from tree shape (#504) — the inference was whitespace-sensitive,
which is what let #505 through. Release tooling also refuses to bump on top of an
unpublished release (#501) and restores its files when a run aborts (#499).
Documentation-only. No library behavior changes; released so the docs site, which mirrors this repo's markdown per release tag, stops describing v0.19.0's headline feature as unsupported.
LVT_MAX_TEMPLATE_DEPTH / WithMaxTemplateDepth had no entry in the
configuration reference at all. (#498)checkFlattenCycle reported "recursive template invocation is not supported"
and its comment claimed livetemplate "does not yet evaluate recursive
invocations at runtime". Neither holds since v0.19.0, and the check is no
longer reachable through FlattenTemplate — detectRecursiveTemplates runs
first over the same invocation edges, so a cycle member is emitted verbatim
rather than pushed onto the inlining stack. Reworded as the internal backstop
it now is. (#498){{template}} invocation — self-referential templates (file trees, comment threads, nested navigation) now render. Previously they were rejected at parse time, because {{template}} calls are inlined during flattening and a self-referential template cannot be inlined. The parser now detects self-referential invocation cycles, leaves them un-inlined, and evaluates them at build time as nested TreeNodes, so the recursive region stays inside the reactive tree rather than degrading to opaque HTML. Dot-rebinding matches html/template semantics. (Tier C · C8)WithMaxTemplateDepth(n) Option and the LVT_MAX_TEMPLATE_DEPTH environment variable cap how deep recursive {{template}} invocations may nest while building the tree, so unbounded recursion (e.g. self-referential data) surfaces a clear error instead of overflowing the stack. Defaults to 128; raise it only if your data is legitimately deeper.["u", key, <recursive diff>] for kept-but-changed items instead of replacing the item's whole subtree. This is framework-wide and benefits every nested or heterogeneous data-keyed range, not just recursive templates: a deep edit now scopes to a nested ["u"] chain down to the changed leaf (~24KB → ~200B on a depth-5 tree, ~100× smaller than the previous opaque re-send). No application change is required. The format is backward-compatible with published clients — @livetemplate/client has merged nested "u" payloads recursively via deepMergeTreeNodes since v0.8.2 — and is validated by the TypeScript-oracle fuzz suite running against the real client.data-key read through the invocation wrapper, giving stable per-item identity across renders instead of falling back to positional keys.ClientVersion is bumped 0.16.5 → 0.18.2, adopting the current published @livetemplate/client. Wire-compatible in both directions for this release's format; the bump additionally picks up two client fixes — lvt-el preserving client-applied class/attr state across morphs, and bare-key lvt-on:keydown shortcuts no longer firing while Ctrl/Meta/Alt is held.{{template}} silently fell back to HTML-string diffing instead of taking the reactive tree path. Body extraction dropped FlattenTemplate's trailing {{define}} blocks, so the recursive invocation had no definition to resolve against; the trailing blocks are now re-attached during extraction.WithStore(store) HandleOption — removed in favor of the existing WithSessionStore(store) New Option. Both set the same SessionStore field, just at different phases (per-Handle vs per-New), and SessionStore was the only dependency configurable at both levels — every other dependency (Authenticator, PubSubBroadcaster, allowed origins, …) binds only at New. No app or example used WithStore; the session store now binds at construction like everything else. Migrate tmpl.Handle(ctrl, state, WithStore(s)) to New(name, WithSessionStore(s)). (#483)Mount (#490).handleNestedTreeNodes exempted range-bearing subtrees from the "statics changed → send the full tree" branch, on the assumption that a range's item changes always travel through the range-diff operations. They only do for a matched range: FindRangeConstructMatches pairs ranges by signature (= the item statics), so a range whose item statics change shape does not match — and the fall-through recursion then finds nothing to send, because a range's content lives in Range.Items, not Dynamics. An item's statics are data-dependent whenever its body has a slot that can vanish: a nested {{template}} that renders nothing for an empty argument contributes no dynamic, so the item's statics differ between the render where it is empty and the one where it is not (the reported case: a comment card whose conversation thread renders only once a reply exists — the reply never reached the open page). The guard is now simply if structureChanged, which is what it always meant: if the statics differ, the client cannot render the subtree from its cache, so it needs the whole thing. Matched ranges are unaffected — they never reach this branch — and keep their item-diff operations. This fixes the statics-shape-change case; signature matching itself is unchanged. (#489)lvt:"persist" field no longer discards the entire restored state on reconnect. A field that is nil when the state is saved is written as JSON null, and json-iterator decodes null into a zero-length json.RawMessage where encoding/json yields the literal "null". InjectPersistFields fed those empty bytes to Unmarshal and errored (ReadMapCB: expect { or n, but found \x00); because it returns on the first error and restorePersistedState drops the state when it does, one nil field silently reverted every other persist field to its zero value — with only a server-log line to show for it. The trigger is ordinary: any persist-tagged map or slice nobody has written to yet. null carries nothing to apply, so it is now skipped and the field is left at its zero value, which is exactly what it round-trips to. This regressed in v0.17.0 with the migration of the remaining encoding/json callsites to jsonutil.API (#231), whose "wire output is unchanged" note held for encoding but not for this decode path. (#489)WithDevMode(true) already relaxes the WebSocket origin check to allow all origins — so pairing it with WithPermissiveOriginCheck() for local development is redundant. This was always the behavior (createSecureOriginChecker short-circuits to allow-all when dev mode is on, and New installs it on the default upgrader), but the WithDevMode godoc, the DevMode field/config comments, and the LVT_DEV_MODE reference docs previously described it as "uses local client library instead of CDN" — a stale claim that never reflected core behavior and led apps to hand-append WithPermissiveOriginCheck(). The godoc now leads with the security semantics ("allows all WebSocket origins, disabling the same-origin/CSRF check — never in production"), notes it also exposes {{.lvt.DevMode}} to templates, and WithPermissiveOriginCheck's godoc points local-dev users to WithDevMode instead, reserving itself for the disable-origin-check-without-dev-mode case. A new TestWithDevModeWiresPermissiveOrigin locks the wiring through New() (dev mode alone allows cross-origin; no options rejects it). No behavior change. (#483) (#487)ClientVersion, ClientScriptURL, and ClientStyleURL exported constants pin the @livetemplate/client browser bundle this LiveTemplate release is wire-compatible with, plus two framework-seeded template functions — lvtClientScriptURL and lvtClientStyleURL — that render those URLs. Templates can now reference {{lvtClientScriptURL}} / {{lvtClientStyleURL}} with no per-app wiring (the funcs are seeded into every template's FuncMap in New, before any parse path runs, so they resolve in full-HTML documents, fragments, and component templates alike; a user Funcs call still overrides them by key). This replaces the previous pattern of hardcoding an unpinned @latest CDN URL — which was unsafe because there is no runtime server↔client version handshake, so a client-only release could ship a wire-protocol change to browsers still talking to an older server. Pinning moves the client version only on a deliberate go get -u, in lockstep with the compatible server. ClientVersion is pinned to the release @latest resolves to today, so behavior is preserved. Self-hosters (offline / air-gapped / CSP-strict) vendor @livetemplate/client@<ClientVersion> and serve it from their own origin instead. (#483)WithParseFS(fsys fs.FS, patterns ...string) Option and (*Template).ParseFS(fsys, patterns...) parse templates directly from an fs.FS (e.g. an embed.FS), so an app can ship templates embedded in its binary without staging them to a temp directory first. WithParseFS takes precedence over WithParseFiles and auto-discovery; the first resolved file is the main template and the rest compose into the same set (same semantics as ParseFiles). ParseFiles and ParseFS now share an internal parseSources core, so ParseFiles behavior is unchanged. (#483)BuildDataMap eagerly evaluated every exported zero-arg method of the State struct on every render — because the struct is converted to a map, and html/template cannot auto-call methods on a map — so an expensive or side-effecting helper method that no template rendered still ran. The render path now computes, once at parse time, the set of identifiers referenced across the templates (a deliberate over-approximation: it unions every field/chain/variable identifier plus every string literal, so a method reached via {{index . "Name"}} is still included) and skips precomputing any method whose name is absent. Rendered output is byte-identical for templates that reference the methods they use. Two limits worth knowing: a method referenced only under a false branch ({{if .Show}}{{.Expensive}}{{end}}) is still evaluated because its name appears in the text; and a method name that collides with an unrelated field or string literal is still (harmlessly) precomputed. Direct callers of BuildDataMap/ExecuteTemplateWithContext pass nil for the new allow-set to keep the original precompute-all behavior. (#462, #255)action.go, state.go, mount.go, health.go, session_stores.go, topic_runtime.go, pubsub/redis.go, internal/upload/protocol.go) from encoding/json to the shared jsonutil.API (json-iterator, ConfigCompatibleWithStandardLibrary), so the production paths now go through one JSON library. Wire output is unchanged (same HTML-escaping and Encoder.Encode trailing newline). Some callsites deliberately keep encoding/json and are not migrated: internal/keys/hash.go uses it as a canonical byte-exact reference for hash-key stability (its Wire-stability invariant forbids any encoder whose bytes could differ), internal/fuzz/* (three files) uses stdlib as the oracle it validates json-iterator against — swapping either would defeat its purpose — and e2e/docker/app/main.go is a demo/e2e fixture app rather than library code. One behavioral nuance: state.go's MarshalBinary/ExtractPersistFields serialize arbitrary user application state, and json-iterator does not detect reference cycles the way encoding/json does — a cyclic user-state struct that previously returned a marshal error now overflows the stack. Such state is already non-persistable under either library; the change is graceful-error → crash for that already-broken edge case. (#231){{.Get}} where Get(key string) string) now errors like text/template (wrong number of args for Get: want 1 got 0) instead of silently stringifying the uncalled method as a Go func value. A bare reference to a variadic method ({{.Tags}} for Tags(...string)) is now called with no arguments, also matching text/template. The with-args method path ({{.ctx.Get "key"}}) is unchanged. The fix lives at the eval-node altitude in internal/parse/eval.go, where the presence of trailing args is known — callMethod/resolveFieldChain run before that and so cannot distinguish "awaiting args" from "bare reference". (#203, #459)__ping__ reserved WebSocket action for a client-driven liveness heartbeat: the event loop replies with a fixed {"pong":true} and skips the action/render pipeline (short-circuited after the message rate limiter). Browsers can't send WebSocket ping control frames from JS, so this app-level round-trip lets a client detect a dead — or zombie (reports OPEN while the TCP is gone, fires no close event) — socket by the absence of a pong and reconnect. Non-heartbeat clients see no behavior change. Documented in docs/references/navigate.md. (#477)Forwarded header (its proto parameter) as a fallback for scheme detection when the de-facto X-Forwarded-Proto is absent or invalid. X-Forwarded-Proto keeps precedence, and both headers remain behind the existing WithTrustForwardedHeaders gate, so a direct (unproxied) client cannot forge either to upgrade the detected scheme. Parsing takes the first (client-most) element's proto, is case-insensitive on the parameter name, and accepts quoted values. (#198)livetemplate_publishes_sent_total now actually increments. From v0.11.0 through v0.15.0 the counter (and its predecessor livetemplate_broadcasts_sent_total) was defined but never wired to a production call site, so it always reported 0 — a footgun for operators with dashboards or alerts on peer-fan-out rate. It is now incremented once per receiving connection whenever a peer-fan-out dispatch is enqueued, covering ctx.Publish, Session.TriggerAction, and cross-instance group/topic re-fan-out. The count is per instance (each instance counts only its own local deliveries, so a clustered publish is not double-counted) and is recorded at enqueue time, so a downstream slow-client close can still drop the resulting WebSocket send. (#432)ctx.Submitter() returns the name of the control that submitted the form (SubmitEvent.submitter.name), mirroring ctx.Action(). Under button-name routing it equals the action; under lvt-on:submit routing the action is the handler while the submitter is the clicked button. Lets BindAndValidate flows branch on the submitter the same way ValidateForm honors formnovalidate. (#239)SaveDraft routes from save-draft (in addition to the existing saveDraft/save_draft/SaveDraft). This makes the documented progressive-enhancement button-name pattern — <button name="save-draft"> — route on the no-JS tier, where the button name is the action verbatim. (#239)ctx.ValidateForm() now honors formnovalidate on submit controls: when a form is submitted by a button/input carrying formnovalidate (e.g. <button name="save-draft" formnovalidate>), validation is skipped. The framework records the control's name from the template into the form schema (FormSchema.NoValidateSubmitters) and matches it against the submission's submitter, so the skip works on every tier — WebSocket, HTTP-fetch, and no-JS native POST — with no client change. Only named submitters are detected; on the no-JS tier the button must not carry a value (the submitter is identified by its empty-value field). The skip is client-controlled convenience, not a security boundary. (#239)WithEphemeralSweepTTL(ttl) HandleOption to tune how long idle HTTP template cache entries survive in ephemeral mode (state with no lvt:"persist" fields) before the sweep loop evicts them. Defaults to 30 minutes; no effect in persistent mode (eviction follows the SessionStore there). A short TTL also tightens the sweep interval (floored at 1 minute) so the value is actually honored. (#304, #305)FormRule): unified the optional-bound sentinels. MinLength/MaxLength no longer use -1 to mean "not set"; all four numeric bounds are now gated by paired Has* booleans — new HasMinLength/HasMaxLength join the existing HasMin/HasMax. Code that constructed a FormRule with MinLength: -1 (or read it expecting the -1 sentinel) must switch to the Has* flags. Callers using ExtractFormSchema/ValidateForm normally are unaffected. (#241)Register*Handler convention, disambiguating one-time handler registration from the per-entity, reference-counted SubscribeTo* subscription methods. Implementers and callers of these pubsub interfaces must update:
Broadcaster.SubscribeServerActions → Broadcaster.RegisterServerActionHandlerGroupActionBroadcaster.SubscribeGroupActions → GroupActionBroadcaster.RegisterGroupActionHandlerTopicActionBroadcaster.SubscribeToTopicActions → TopicActionBroadcaster.RegisterTopicActionHandlerWSIsUpgrade now fully validates the RFC 6455 handshake: in addition to the GET method and Connection: upgrade / Upgrade: websocket headers, it requires a non-empty Sec-WebSocket-Key and Sec-WebSocket-Version: 13. Requests missing these are routed as plain HTTP (they would have failed at Upgrade() anyway); well-behaved WebSocket clients always send them. (#243, #244, #245, #247)<!-- ... -->) are now stripped from template output, matching html/template (which removes them during its escape pass). LiveTemplate builds statics by walking the raw parse tree, which never triggers that pass, so developer/internal comments previously shipped verbatim to the client (visible in view-source) and over WebSocket tree updates. Stripping uses the HTML tokenizer, so comment-like text in attribute values is preserved and comments inside <script>/<style>/<textarea> are left verbatim. See docs/references/template-support-matrix.md for residual divergences. (#468)tdewolff/minify) is HTML-tag-aware but CSS-blind, so it silently collapsed whitespace inside elements made whitespace-significant by a CSS class (e.g. white-space: pre-wrap on <span class="chroma">), corrupting syntax-highlighted code, diffs, and ASCII art rendered through the structure-based/diff tree paths; text-only dynamics were likewise trimmed and space-collapsed. Dynamic content now passes through verbatim, matching how statics are already handled. The internal/render.MinifyHTML helper and the github.com/tdewolff/minify/v2 dependency were removed. (#467)NewGorillaUpgrader now gives each upgrader its own write-buffer sync.Pool instead of sharing a package-level global, so upgraders configured with different WriteBufferSize values can no longer draw mismatched buffers from a shared pool. (#243)ExtractFormSchema no longer emits a validation rule for submit controls (<input type="submit|image|button|reset">). Such controls carry no payload field, so a required (or other validation attribute) on a named submit input previously produced a FormRule that could raise a spurious "field is required" error whenever that control was not the submitter. This is a behavior change to a public function, independent of the formnovalidate skip. (#239)livetemplate_broadcasts_sent_total is now
livetemplate_publishes_sent_total. The rename reflects the post-v0.10.0 ctx.Publish /
ctx.Subscribe API and removes the lingering ambiguity between the old BroadcastAction
vocabulary and the new pub/sub vocabulary. The metric's help text now reads
"Total number of peer-fan-out publishes sent (ctx.Publish)".BroadcastFailures in docs/guides/OBSERVABILITY.md
is now PublishFailures; the condition expression uses publish_errors_per_minute.internal/observe —
(*observe.Metrics).BroadcastSent() → PublishSent(), and
MetricsSnapshot.BroadcastsSent → PublishesSent. External users importing only the
public MetricsHandler() API are unaffected.There is no dual-emit period. Operators must update dashboards, recording rules, and alert configurations in lockstep with the deploy. See the Metric Migration section in OBSERVABILITY.md for the sed one-liners.
Scrub residual "broadcast" vocabulary from non-history docs/comments where it was
ambiguous next to the new pub/sub API: WithPubSubBroadcaster doc comment (which
referenced now-removed BroadcastTo* methods), WithDispatchBufferSize doc, package
doc comment, Context.ConnectKind comment, CONTRIBUTING.md directory map,
docs/guides/SCALING.md, docs/guides/OBSERVABILITY.md, docs/guides/standard-html-reactivity.md,
docs/references/pubsub.md (the "Broadcast Scopes" heading is preserved because it documents
the literal livetemplate:broadcast:* channel namespace), docs/references/error-handling.md,
docs/references/api-reference.md, docs/guides/new-contributor-walkthrough.md, and
CLAUDE.md. Public API type/identifier names (Broadcaster, RedisBroadcaster,
WithPubSubBroadcaster, pubsub.BroadcastMessage) are unchanged; wire-format Redis
channel names (livetemplate:broadcast:*) are unchanged.
actions no longer auto-broadcast state or persist to SessionStore.
Key changes:
Added retroactively — this change shipped in v0.8.5 but was not recorded at the time.
TreeNode internal API changed in #220 (commit 3fe784ca). The Dynamics field type and the helper signatures changed when the internal map was replaced with a slice for ~20% speedup:
Dynamics field: map[string]interface{} → []interface{}SetDynamic(position string, value interface{}) → SetDynamic(index int, value interface{})GetDynamic(position string) → GetDynamic(index int)AutoKey string Go field replaces the previous "_k" map key. The "_k" wire-format key is unchanged — only the in-memory Go field name moved.TreeNode lives in internal/build and is not part of the public livetemplate API surface; the breaking surface is therefore limited to library forks and downstream modules that vendor or replace internal/build, plus internal test fixtures. Application code that consumes livetemplate through its exported API is unaffected. The on-the-wire tree format (numeric string keys: "0", "1", ...) is unchanged; only the in-memory Go API moved.
SessionStore methods now require context.Context parameter
This change adds proper context propagation throughout the session store layer, enabling timeout control, cancellation, and tracing for all Redis and session operations.
Changes to SessionStore interface:
Implementation updates:
MemorySessionStore:
RedisSessionStore:
Benefits:
Migration guide:
No - added field to struct, backward compatible.
Note: Only one pre-existing test failure (TestTemplateGenerateTreeWithFuncMap)
🤖 Generated with Claude Code