diff --git a/content/changelog/livetemplate.md b/content/changelog/livetemplate.md index cec0d2d..45585bf 100644 --- a/content/changelog/livetemplate.md +++ b/content/changelog/livetemplate.md @@ -2,8 +2,8 @@ title: "Changelog" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "CHANGELOG.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Changelog @@ -13,6 +13,35 @@ All notable changes to LiveTemplate will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.23.0] - 2026-08-02 + +### Added + +- **`LiveHandler.Func()` returns `ServeHTTP` as an `http.HandlerFunc`.** The + value `Template.Handle()` returns already satisfied `http.Handler`, so + `http.Handle`/`mux.Handle` worked, but the stdlib entry points that take a + function — `http.HandleFunc`, and `ServeMux.HandleFunc` with Go 1.22 method + patterns — required spelling out `handler.ServeHTTP`. `Func()` is that method + value, so `http.HandleFunc("/counter", handler.Func())` and + `mux.HandleFunc("GET /counter", handler.Func())` read naturally. It is an + accessor, not a downgrade: `Shutdown`, `Publish` and `MetricsHandler` stay + available on the `LiveHandler` it came from. + +### Changed + +- **A failed WebSocket upgrade now logs a `hint` when the `http.ResponseWriter` + does not implement `http.Hijacker`.** An upgrade takes over the raw + connection, so middleware that wraps the writer (logging, gzip, status + capture) without forwarding `Hijack` breaks it — while GET and POST keep + rendering, making the symptom "the page renders but never goes live". The + underlying upgrader error names `http.Hijacker` but not the middleware that + caused it; the hint does, and points at forwarding `Hijack` or leaving the + writer unwrapped when `livetemplate.WSIsUpgrade(r)` is true. It is attached on + the writer's own defect, which need not be what the accompanying error reports + — an upgrader can reject a handshake earlier (a disallowed `Origin`) and never + reach the hijack — so it is worded as a second failure the upgrade would have + hit regardless, rather than as the reported cause. + ## [v0.22.0] - 2026-07-26 ### Added diff --git a/content/contributing/livetemplate.md b/content/contributing/livetemplate.md index 31f4642..25fbd03 100644 --- a/content/contributing/livetemplate.md +++ b/content/contributing/livetemplate.md @@ -2,8 +2,8 @@ title: "Contributing to LiveTemplate Core Library" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "CONTRIBUTING.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Contributing to LiveTemplate Core Library @@ -226,7 +226,7 @@ livetemplate/ └── scripts/ # Development scripts ``` -For the complete file-by-file map with line counts and dependencies, see [docs/design/CODE_STRUCTURE.md](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/design/CODE_STRUCTURE.md). +For the complete file-by-file map with line counts and dependencies, see [docs/design/CODE_STRUCTURE.md](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/design/CODE_STRUCTURE.md). **Note:** The client library, CLI tool, and examples are now in separate repositories: - Client: https://github.com/livetemplate/client @@ -528,7 +528,7 @@ Look for issues labeled `good first issue` - these are: ### Learning the Codebase 1. **Start with the Contributor Walkthrough** - - [`docs/guides/new-contributor-walkthrough.md`](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/guides/new-contributor-walkthrough.md) - **START HERE!** Comprehensive guide to the 5-phase architecture with links to all code and tests + - [`docs/guides/new-contributor-walkthrough.md`](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/guides/new-contributor-walkthrough.md) - **START HERE!** Comprehensive guide to the 5-phase architecture with links to all code and tests 2. **Read the architecture docs** - `CLAUDE.md` - Development guidelines diff --git a/content/guides/ephemeral-components.md b/content/guides/ephemeral-components.md index ce34da4..c56578c 100644 --- a/content/guides/ephemeral-components.md +++ b/content/guides/ephemeral-components.md @@ -2,8 +2,8 @@ title: "Ephemeral Components Guide" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/ephemeral-components.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Ephemeral Components Guide @@ -62,7 +62,7 @@ type AppState struct { } ``` -> **Note on `AssertPureState`**: If your tests use `lvt/testing.AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/guides/t)` to verify state contains no dependency types, `*toast.Container` will need to be excluded. Component containers are not external dependencies — they hold transient UI data, not connections or handles. Use `AssertPureState` with the `IgnoreFields` option, or structure your state so component fields live in a separate struct that is not checked. +> **Note on `AssertPureState`**: If your tests use `lvt/testing.AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/guides/t)` to verify state contains no dependency types, `*toast.Container` will need to be excluded. Component containers are not external dependencies — they hold transient UI data, not connections or handles. Use `AssertPureState` with the `IgnoreFields` option, or structure your state so component fields live in a separate struct that is not checked. ### Initialization diff --git a/content/guides/observability.md b/content/guides/observability.md index 7dbec02..b90d2d8 100644 --- a/content/guides/observability.md +++ b/content/guides/observability.md @@ -2,8 +2,8 @@ title: "LiveTemplate Observability Guide" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/OBSERVABILITY.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # LiveTemplate Observability Guide @@ -409,6 +409,6 @@ func RequestIDMiddleware(next http.Handler) http.Handler { ## Related Documentation -- [ARCHITECTURE.md](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/guides/ARCHITECTURE.md) - System architecture overview -- [internal/observe/](https://github.com/livetemplate/livetemplate/tree/v0.22.0/docs/internal/observe) - Package implementation +- [ARCHITECTURE.md](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/design/ARCHITECTURE.md) - System architecture overview +- [internal/observe/](https://github.com/livetemplate/livetemplate/tree/v0.23.0/internal/observe) - Package implementation - [Go slog documentation](https://pkg.go.dev/log/slog) - Standard library reference diff --git a/content/guides/progressive-complexity.md b/content/guides/progressive-complexity.md index 5002f7d..3464f96 100644 --- a/content/guides/progressive-complexity.md +++ b/content/guides/progressive-complexity.md @@ -2,8 +2,8 @@ title: "Progressive Complexity Guide" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/progressive-complexity.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Progressive Complexity Guide @@ -236,10 +236,11 @@ LiveTemplate offers three loading models. Pick the simplest one that fits your u |---|---|---|---| | Grey out the form | N/A | **7.1 — Auto** (`
` + CSS) | 0 lines, 0 attrs | | Custom loading UX (spinner, text) | Yes | **7.2 — Client-owned pending** (`lvt-el:*:on:pending`) | 0 lines, 2 attrs | -| Custom loading UX (spinner, text) | **No** | **7.3 — Server-owned loading** with `Async` + `{{.lvt.Pending}}` | ~5 lines, 0 attrs | -| Loading fans out to peers / survives reconnect | Either | **7.3 — Server-owned loading** with manual `Loading` field + `ctx.Publish()` | ~15 lines, 0 attrs | +| Custom loading UX (spinner, text) | **No** | **7.3 — Server-owned loading** with `Async` + `{{.lvt.Pending}}` | ~7 lines, 0 attrs | +| Loading fans out to peers / survives reconnect | Either | **7.3 — Server-owned loading** with `Async` + a `Loading` field | ~9 lines, 0 attrs | +| Work starts in `Mount`/`OnConnect`, or reports progress repeatedly | Either | **7.3 — Escape hatch**: manual two-action pattern | ~15 lines, 0 attrs | -**Rule of thumb:** start with 7.1. Move to 7.2 or 7.3 only when you need custom UX (spinners, text changes, progress indicators) or loading that is real application state. +**Rule of thumb:** start with 7.1. Move to 7.2 or 7.3 only when you need custom UX (spinners, text changes, progress indicators) or loading that is real application state. Within 7.3, reach for the manual two-action pattern only when `Async` is unavailable — it is not the default any more. ### 7.1 Automatic Form Loading (Tier 1) @@ -293,66 +294,67 @@ The `pending` state fires instantly on click (before the server even receives th **Trade-offs:** the pending state is client-only — it does not fan out to peer tabs, does not survive reconnect, and cannot drive server-side logic. The action blocks the event loop for its duration (no other clicks or peer pushes until it returns). For loading that is real application state, use 7.3. +It *does* survive a server re-render. Since `@livetemplate/client` v0.18.1 the classes and attributes applied by `lvt-el:` are client-owned: the morph pass re-applies them after each server patch, so re-rendering the element does not wipe the pending class. You do not need `lvt-ignore-attrs` to protect them. + See the [Client Attributes Reference — Reactive Attributes](/reference/client-attributes#reactive-attributes) for the full `lvt-el:*` pattern. ### 7.3 Server-Owned Loading (Tier 1) -When loading is real application state — it needs to fan out to peers, survive reconnect, or drive server-side logic — use a server-owned `Loading` field in your state with `{{if .Loading}}` in the template. This keeps everything in Tier 1 (no `lvt-*` attributes), but requires the manual two-action pattern: +When loading is real application state — it needs to survive reconnect, fan out to peers, or drive server-side logic — model it on the server and render it with ordinary template conditionals. This keeps everything in Tier 1 (no `lvt-*` attributes). + +`livetemplate.Async` is the primitive. It runs slow work off the event loop, then re-enters the loop to apply the result to the current state and re-render — one method, no manual goroutine, no dispatch plumbing: ```go -type State struct { - Name string - Loading bool -} +func Async[S any, R any]( + ctx *Context, + work func(context.Context) (R, error), + apply func(s S, result R, err error) (S, error), +) +``` -// Action 1: set Loading=true, spawn the slow work off the event loop +**Scope:** `Async` is supported only inside **action handlers** (e.g. `Greet`, `Save`) that run on the per-connection WebSocket event loop. Calling it from `Mount()`, `OnConnect()`, dispatched actions, server-initiated actions, upload handlers, or HTTP POST handlers logs a warning and drops the operation — there is no event loop to re-enter. Those cases need the [manual two-action pattern](#escape-hatch-the-manual-two-action-pattern) below. + +#### The default: `Async` + `{{.lvt.Pending}}` + +When the loading indicator is purely visual, you do not need a `Loading` field at all. `{{.lvt.Pending}}` is a framework-provided template variable — `true` on the render that registered async work, `false` on every other render (including the completion render): + +```go func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error) { - if state.Loading { - return state, nil // re-entrancy guard: ignore clicks while loading - } - session := ctx.Session() - if session == nil { - return state, nil // nil check: no session on initial HTTP render - } name := strings.TrimSpace(ctx.GetString("name")) - state.Loading = true - go func() { - time.Sleep(700 * time.Millisecond) // simulate slow work - _ = session.TriggerAction("finishGreet", map[string]any{"name": name}) - }() - return state, nil // render #1: spinner on -} - -// Action 2: clear Loading, apply the result -func (c *Controller) FinishGreet(state State, ctx *livetemplate.Context) (State, error) { - state.Name = ctx.GetString("name") - state.Loading = false - return state, nil // render #2: spinner off + livetemplate.Async(ctx, + func(ctx context.Context) (string, error) { + time.Sleep(700 * time.Millisecond) // simulate slow work + return name, nil + }, + func(s State, name string, err error) (State, error) { + s.Name = name + return s, nil + }, + ) + return state, nil } ``` ```html - ``` -**Why two methods?** LiveTemplate's event loop processes one action per render cycle. To show a spinner *and later* clear it, the slow work must return early (render #1: spinner on) and re-enter the event loop via `Session.TriggerAction` (render #2: spinner off). - -**Four things to get right:** - -1. **Re-entrancy guard** (`if state.Loading { return }`): prevents double-clicks from spawning duplicate goroutines. The `{{if .Loading}}disabled{{end}}` on the button is the UI-level guard; the Go check is the server-level backup. -2. **Session nil-check** (`if session == nil`): `ctx.Session()` returns nil during initial HTTP renders (before WebSocket connects). The goroutine + `TriggerAction` pattern requires a live session. -3. **Goroutine lifetime**: the goroutine should be short-lived. If the connection drops, `TriggerAction` returns `ErrSessionDisconnected` — the goroutine exits cleanly. -4. **Second action name**: the `FinishGreet` method name must match the string passed to `TriggerAction`. A typo silently fails (the action dispatches but no method handles it). +No `Loading` field, no `lvt-*` attributes, one method — pure Go and standard HTML templates. -**Trade-offs vs 7.2:** more verbose (~15 lines across 2 methods), but the loading state is real server state — it fans out to peers via `ctx.Publish()`, survives reconnect (it's in the state struct), and can drive server-side logic (e.g., preventing concurrent operations). +`{{.lvt.Pending}}` has **per-render** semantics: if another action or a peer dispatch triggers a render on the same connection while the work is still in flight, that render sees `Pending=false`. For an indicator that must stay visible across interleaved renders, use an explicit field — next section. -#### Simplified with `Async` +#### When loading is real state: `Async` + a `Loading` field -`livetemplate.Async` collapses the two-method pattern to one method (~7 lines) by handling the goroutine, dispatch channel, and re-entry automatically: +Keep a `Loading` field when the loading state has to do more than paint the screen — drive a re-entrancy guard, fan out to peers, or survive a reconnect: ```go +type State struct { + Name string + Loading bool +} + func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error) { state.Loading = true name := strings.TrimSpace(ctx.GetString("name")) @@ -372,41 +374,70 @@ func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error } ``` -The template is identical — `{{if .Loading}}` works the same way. The key guarantees: -- **`apply` sees the current state**, not a snapshot — any actions that ran during the async window are visible -- **Connection-scoped** — only the originating connection gets the completion render -- **Lifetime-bound** — if the connection closes, the goroutine is cancelled and `apply` is skipped +```html + +``` + +The key guarantees: +- **`apply` sees the current state**, not a snapshot — any actions that ran during the async window are visible. Mutate only the fields you own. +- **`work` must not touch session state** — only its own inputs. It receives a `context.Context` tied to the connection's lifetime. +- **Connection-scoped** — only the originating connection gets the completion render. To fan out to peers, capture `session := ctx.Session()` *before* defining `apply` and call `session.TriggerAction()` from inside it; `ctx` itself is not in scope there. +- **Lifetime-bound** — if the connection closes before `work` completes, the goroutine is cancelled and `apply` never runs. + +If the indicator must survive a **reconnect**, the field has to carry the `lvt:"persist"` tag. Unpersisted fields reset to their zero value on reconnect, so a guard reading an untagged `Loading` never fires on the new connection. See the [Async API reference](/reference/api#async) for the full contract. -#### Zero-boilerplate with `{{.lvt.Pending}}` +#### Escape hatch: the manual two-action pattern -When loading is purely visual (no need for a `Loading` field in state), combine `Async` with the framework-provided `{{.lvt.Pending}}` template variable. It is `true` on the render that registered async work and `false` on all other renders (including the async completion render): +Before `Async`, server-owned loading required two methods: one to set `Loading=true` and spawn the work, another to apply the result. You still need that shape when: + +- **the work starts where `Async` is illegal** — `Mount`, `OnConnect`, upload handlers, HTTP POST handlers (see *Scope* above); +- **the work reports progress repeatedly** rather than completing once. `Async` is one-shot — one `work`, one `apply` — so a ticker, a progress bar, or a streaming job still spawns a goroutine that calls `TriggerAction` per update; +- **you are re-spawning in-flight work after a reconnect**, which by definition begins in `OnConnect`. ```go +type State struct { + Name string + Loading bool +} + +// Action 1: set Loading=true, spawn the slow work off the event loop func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error) { + if state.Loading { + return state, nil // re-entrancy guard: ignore clicks while loading + } + session := ctx.Session() + if session == nil { + return state, nil // nil check: no session on initial HTTP render + } name := strings.TrimSpace(ctx.GetString("name")) - livetemplate.Async(ctx, - func(ctx context.Context) (string, error) { - time.Sleep(700 * time.Millisecond) - return name, nil - }, - func(s State, name string, err error) (State, error) { - s.Name = name - return s, nil - }, - ) - return state, nil + state.Loading = true + go func() { + time.Sleep(700 * time.Millisecond) // simulate slow work + _ = session.TriggerAction("finishGreet", map[string]any{"name": name}) + }() + return state, nil // render #1: spinner on } -``` -```html - +// Action 2: clear Loading, apply the result +func (c *Controller) FinishGreet(state State, ctx *livetemplate.Context) (State, error) { + state.Name = ctx.GetString("name") + state.Loading = false + return state, nil // render #2: spinner off +} ``` -No `Loading` field, no `lvt-*` attributes — pure Go + standard HTML templates. Use `{{.lvt.Pending}}` when the loading indicator is chrome (visual feedback). Use an explicit `Loading` state field (with `Async`) when loading is real application state that needs to fan out to peers or survive reconnect. +**Why two methods?** LiveTemplate's event loop processes one action per render cycle. To show a spinner *and later* clear it, the slow work must return early (render #1: spinner on) and re-enter the event loop via `Session.TriggerAction` (render #2: spinner off). `Async` does exactly this for you — the two renders are the same, only the bookkeeping moves into the framework. + +**Four things to get right** (all of them handled for you by `Async`): + +1. **Re-entrancy guard** (`if state.Loading { return }`): prevents double-clicks from spawning duplicate goroutines. The `{{if .Loading}}disabled{{end}}` on the button is the UI-level guard; the Go check is the server-level backup. +2. **Session nil-check** (`if session == nil`): `ctx.Session()` returns nil during initial HTTP renders (before WebSocket connects). The goroutine + `TriggerAction` pattern requires a live session. +3. **Goroutine lifetime**: the goroutine should be short-lived. If the connection drops, `TriggerAction` returns `ErrSessionDisconnected` — the goroutine exits cleanly. +4. **Second action name**: the `FinishGreet` method name must match the string passed to `TriggerAction`. A typo silently fails (the action dispatches but no method handles it). --- diff --git a/content/guides/scaling.md b/content/guides/scaling.md index f68054f..6bd6605 100644 --- a/content/guides/scaling.md +++ b/content/guides/scaling.md @@ -2,8 +2,8 @@ title: "LiveTemplate Scaling Guide" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/SCALING.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # LiveTemplate Scaling Guide @@ -1401,7 +1401,7 @@ If issues arise after migration: 3. **Set up alerting** for Redis connectivity issues 4. **Review capacity planning** for expected load -See [SESSION.md](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/guides/SESSION.md) for the Session API guide on server-initiated actions. +See [session.md](/reference/session) for the Session API guide on server-initiated actions. --- @@ -1869,8 +1869,8 @@ redis_connected_clients{instance="redis1"} > 9000 # 90% of Redis max clients ## Next Steps -- **Roadmap:** See [ROADMAP.md](https://github.com/livetemplate/livetemplate/blob/v0.22.0/ROADMAP.md) for upcoming scaling features -- **Architecture:** See [ARCHITECTURE.md](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/guides/ARCHITECTURE.md) for system design +- **Roadmap:** See [ROADMAP.md](https://github.com/livetemplate/livetemplate/blob/v0.23.0/ROADMAP.md) for upcoming scaling features +- **Architecture:** See [ARCHITECTURE.md](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/design/ARCHITECTURE.md) for system design --- diff --git a/content/guides/standard-html-reactivity.md b/content/guides/standard-html-reactivity.md index f344cc3..36aff4c 100644 --- a/content/guides/standard-html-reactivity.md +++ b/content/guides/standard-html-reactivity.md @@ -2,8 +2,8 @@ title: "Standard HTML Reactivity" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/standard-html-reactivity.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Standard HTML Reactivity @@ -151,7 +151,7 @@ LiveTemplate is inspired by Phoenix LiveView but does not yet cover its full fea | **Stateful Components** | `LiveComponent` with own lifecycle | Stateless templates only | `{{template}}` invocations work but have no component-level state or event handling. | | **Streams** | `stream/3` for large lists | Not yet | LiveView streams handle large/infinite lists without keeping all items in server memory. Streaming-range rendering (PRs #366/#368/#369/#370) is the latest step toward this. | | **JS Commands** | `JS.push`, `JS.toggle`, `JS.show` | Partial | [`lvt-*` reactive attributes](/reference/client-attributes) cover common cases (disable, add/remove class, set attribute) but aren't as composable as LiveView's server-defined JS chains. | -| **Client Hooks** | `phx-hook` lifecycle callbacks | Proposed | [`lvt-hook` proposal](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/proposals/lifecycle-hooks-proposal.md) covers third-party JS library integration; not yet shipped. | +| **Client Hooks** | `phx-hook` lifecycle callbacks | Proposed | [`lvt-hook` proposal](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/proposals/lifecycle-hooks-proposal.md) covers third-party JS library integration; not yet shipped. | | **Presence** | `Phoenix.Presence` | Not built-in | Can be built on LiveTemplate's session stores; requires manual implementation. | | **Testing Helpers** | `live/2`, `render_click/3` | Minimal | `AssertPureState` exists; no view-level test DSL. Browser tests use chromedp. | | **Form Recovery** | Automatic on reconnect | Partial — `lvt-form:preserve` retains specific fields across re-renders | Full automatic recovery on WS reconnection is not yet built in. | diff --git a/content/reference/api.md b/content/reference/api.md index c35eb9a..07762b3 100644 --- a/content/reference/api.md +++ b/content/reference/api.md @@ -2,8 +2,8 @@ title: "Go Library API Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/api-reference.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Go Library API Reference @@ -117,6 +117,70 @@ handler := tmpl.Handle(&TodoController{DB: db}, livetemplate.AsState(&TodoState{ --- +## Validate + +```go +func Validate(templateText string, opts ...ValidateOption) ([]Diagnostic, error) +``` + +Parses `templateText` the way the live renderer does — against the framework's real function set and any supplied component templates — and returns the problems found. An empty slice means the template parses cleanly and will be served rather than silently dropped. + +**Why this exists:** `Execute` and `ExecuteUpdates` catch a first-render failure and fall back to an HTML-structure tree. A malformed template — an unclosed `{{range}}`, an unknown function, an unresolved `{{template}}` — therefore renders *degraded* and returns no error. A tool that wants to reject a bad template **before** serving it had nothing to call. `Validate` is that call. + +```go +type Diagnostic struct { + Line int // 1-based line in the supplied text; 0 if the parser reported none + Severity Severity // SeverityError today; SeverityWarning is reserved + Message string +} +``` + +| Severity | Meaning | +|---|---| +| `SeverityError` | A problem that prevents the template from being served: the block is dropped at serve time and renders nothing. | +| `SeverityWarning` | Reserved for problems that degrade a template rather than break it — the home for the data-dependent checks a future sample-data mode would surface. Not emitted today. | + +**Diagnostics are not errors.** The returned `error` is reserved for infrastructure failures — a component set that itself fails to parse, or an internal fault. A template that does not parse is *always* reported as a `Diagnostic`, never as an `error`. This mirrors the shape of a linter: problems in the input are data, not errors. + +```go +diags, err := livetemplate.Validate(text) +if err != nil { + return fmt.Errorf("validation could not run: %w", err) // infrastructure fault +} +for _, d := range diags { + fmt.Printf("%s:%d: %s: %s\n", path, d.Line, d.Severity, d.Message) +} +if len(diags) > 0 { + return errors.New("template rejected") +} +``` + +**What it checks:** the syntax and composition layer — unclosed or malformed actions (`{{range}}`, `{{if}}`, `{{with}}`), unknown functions (checked against the framework's own builtins, which a downstream caller cannot enumerate, so this check cannot be reproduced outside the module), and unresolved component or composition templates. + +**What it does not check:** data-dependent problems that only surface when the template is executed against a value. Those are out of scope until a sample-data mode is added. + +**At most one diagnostic per call today.** The underlying parser stops at the first error rather than recovering, so the slice has length 0 or 1. The slice shape anticipates the multi-error reporting a recovering pass could add. + +### Validating templates that use components + +```go +func WithValidateComponents(sets ...*TemplateSet) ValidateOption +``` + +Makes the given component template sets available, so a template invoking `{{template "ns:name" .}}` resolves the same way it does at serve time. Pass the same sets you pass to `New(WithComponentTemplates(...))`; **without them a component reference is reported — correctly — as an unresolved template.** + +```go +diags, err := livetemplate.Validate(text, + livetemplate.WithValidateComponents(uiKit), +) +``` + +Each call re-parses the supplied component sets from their `fs.FS`. That suits pre-serve and dev-time validation rather than per-keystroke use against a large component library. + +> **Line-number caveat:** HTML comments are stripped before parsing (matching serve), so a diagnostic below a multi-line HTML comment can report a line shifted by the comment's height. + +--- + ## Controller+State Pattern For patterns, examples, and usage guide, see [Controller+State Pattern](/reference/controller-pattern). @@ -325,6 +389,12 @@ from ~15 lines / 2 methods to ~7 lines / 1 method. `session := ctx.Session()` before defining `apply`, then call `session.TriggerAction()` from within the `apply` closure (`ctx` itself is not in scope inside `apply`). +- **`apply` receives only `(state, result, err)`** — there is no `*Context`, + so the completion render cannot set a flash, write a cookie, or navigate. + Anything that needs `ctx` on the *second* render has to arrive as a real + action: keep the manual two-action pattern, or capture the session and + `TriggerAction` from inside `apply`. State changes are unaffected — those + are what `apply` returns. **Example:** @@ -383,6 +453,8 @@ type LiveHandler interface { http.Handler Shutdown(ctx context.Context) error MetricsHandler() http.Handler + Publish(topic, action string, data map[string]interface{}) error + Func() http.HandlerFunc } ``` @@ -393,6 +465,54 @@ Returned by `Template.Handle()`. Serves both HTTP and WebSocket requests. | `ServeHTTP` | Handles HTTP requests and WebSocket upgrades | | `Shutdown` | Gracefully drains connections with context timeout | | `MetricsHandler` | Returns Prometheus metrics endpoint handler | +| `Publish` | Fans a topic action out to subscribers from outside an action handler ([PubSub](/reference/pubsub)) | +| `Func` | Returns `ServeHTTP` as an `http.HandlerFunc` | + +### Standard library integration + +A `LiveHandler` is an ordinary `net/http` handler — one `ServeHTTP` serves the +initial GET render, form-action POSTs, and the WebSocket upgrade. It composes +with `net/http` routing directly, and `Func()` covers the entry points that take +a function instead of an `http.Handler`: + +```go +handler := tmpl.Handle(&CounterController{}, livetemplate.AsState(&CounterState{})) + +http.Handle("/counter", handler) // http.Handler +mux.Handle("/counter", handler) // ServeMux +http.HandleFunc("/counter", handler.Func()) // http.HandlerFunc +mux.HandleFunc("GET /counter", handler.Func()) // Go 1.22+ method patterns +mux.HandleFunc("POST /counter", handler.Func()) // form actions still dispatch +``` + +`Func()` is exactly `handler.ServeHTTP` — neither form is preferred, and taking +it does not give up `Shutdown`, `Publish` or `MetricsHandler`, which stay on the +`LiveHandler` it came from. + +Mounting under a subtree with `http.StripPrefix` is supported; the handler reads +the request path as rewritten. + +**Middleware must forward `http.Hijacker`.** A WebSocket upgrade takes over the +raw connection, so it needs the `http.ResponseWriter` to implement +`http.Hijacker`. Middleware that only observes the request is fine; middleware +that *wraps* the writer (logging, gzip, status capture) hides `Hijack` unless it +forwards the method: + +```go +// Breaks the upgrade: embedding promotes Write/Header/WriteHeader, not Hijack. +type wrapped struct{ http.ResponseWriter } +``` + +The failure is partial and easy to miss — GET and POST keep rendering, only the +upgrade is refused with a 500 — so a failed upgrade logs a `hint` naming +middleware whenever the writer is not hijackable. To keep such middleware, +either forward `Hijack` to the underlying writer, or leave the writer unwrapped +when `livetemplate.WSIsUpgrade(r)` is true. + +The hint is attached on the writer's own defect, which need not be what the +accompanying `error` reports — an upgrader can reject a handshake earlier (a +disallowed `Origin`, say) and never reach the hijack. Read it as a second +failure the upgrade would have hit regardless, not as the reported cause. --- diff --git a/content/reference/authentication.md b/content/reference/authentication.md index 4261ce3..26c052d 100644 --- a/content/reference/authentication.md +++ b/content/reference/authentication.md @@ -2,8 +2,8 @@ title: "Authentication Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/authentication.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Authentication Reference diff --git a/content/reference/client-attributes.md b/content/reference/client-attributes.md index 5dc459d..a69dacb 100644 --- a/content/reference/client-attributes.md +++ b/content/reference/client-attributes.md @@ -2,8 +2,8 @@ title: "Client Attributes Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/client-attributes.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Client Attributes Reference @@ -873,6 +873,26 @@ Any form element that currently has focus is skipped during morphdom updates, pr To override this for a specific input — e.g., when a server-controlled value must always win — add `data-lvt-force-update` to the element. +### Client-Applied Classes and Attributes (`lvt-el:`) + +Classes and attributes applied by the five state-mutating [`lvt-el:` actions](#reactive-attributes) — `addClass`, `removeClass`, `toggleClass`, `setAttr`, `toggleAttr` — are **client-owned**. The client records what it applied and re-applies that record onto the incoming element before morphdom compares them, so a server re-render does not wipe it. + +```html + + +``` + +The rules: + +- **The client overlay wins on conflict.** A `removeClass` of a class the server still emits also survives — which is what makes click-away dismissal work. +- **The server re-owns on agreement.** Once the server's own output matches what the client applied, the record self-cleans. Toggling back likewise retires the entry. +- **It is not gated on the directive still being present.** This is persistent state, not a one-shot latch — a `data-lvt-target`-ed binding lands on an element carrying no `lvt-el:*` attribute at all, and its state is still preserved. +- **Element identity is required.** The `replaceWith` / `replaceChildren` paths destroy the node, so client-owned state legitimately dies with it. + +You do **not** need `lvt-ignore-attrs` here — see the note under [Manual Preservation](#manual-preservation-with-lvt-ignore) for why it never worked for this case. + +**Requires `@livetemplate/client` v0.18.1 or newer.** + ### Overriding with `data-lvt-force-update` All automatic preservation behaviors can be overridden by adding `data-lvt-force-update` to the element in the server template. When present, the server's value wins over the client-side state. The client strips the attribute from the live DOM after applying the update; because it lives in the server template, the server re-sends it on every render, so it continuously forces the server value. @@ -888,6 +908,7 @@ All automatic preservation behaviors can be overridden by adding `data-lvt-force | Dialog `open` | morphdom update skipped while dialog is open | `data-lvt-force-update` on the dialog | | Datalist dropdown | Entire morphdom pass deferred while datalist input focused | `data-lvt-force-update` on the connected `` (overrides deferral for the entire pass) | | Focused input elements | morphdom update skipped | `data-lvt-force-update` on the input | +| Client-applied `lvt-el:` classes/attrs | Overlay record re-applied to the incoming element before morphdom | Server re-owns automatically once its output agrees; the record self-cleans | ### Manual Preservation with `lvt-ignore` @@ -897,6 +918,8 @@ For cases where automatic preservation doesn't cover your needs, two attributes - **`lvt-ignore-attrs`** — Skips attribute diffing but still diffs children. Use this when client-set attributes (e.g., `open` on `
`) need to survive server updates while child content remains server-managed. + > **Not the tool for `lvt-el:` state.** `lvt-ignore-attrs` only copies attributes the incoming element *lacks*, and any element using `toggleClass` already carries a server-emitted base `class` — so `class` was always skipped and the client's token was lost anyway. Classes and attributes applied by `lvt-el:` are preserved automatically; see [Client-Applied Classes and Attributes](#client-applied-classes-and-attributes-lvt-el). + Both can be overridden by `data-lvt-force-update` when the server needs to take control — adding it to an `lvt-ignore` element re-enables morphdom for that subtree for the current update. --- @@ -1064,7 +1087,7 @@ Directives use CSS custom properties for configuration: `--lvt-scroll-behavior`, | Attribute | Description | Example | |-----------|-------------|---------| | `lvt-ignore` | Skip this element and its entire subtree during morphdom diff. Checked on the live DOM (`fromEl`), usable from both templates and client JS. Equivalent to Phoenix LiveView's `phx-update="ignore"` | `
` | -| `lvt-ignore-attrs` | Skip attribute diffing but still diff children. Preserves client-set attributes (e.g. `open` on `
`) while keeping child content server-managed | `
` | +| `lvt-ignore-attrs` | Skip attribute diffing but still diff children. Preserves client-set attributes (e.g. `open` on `
`) while keeping child content server-managed. Not needed for `lvt-el:`-applied classes/attrs, which are preserved automatically | `
` | | `data-lvt-force-update` | Override all preservation (automatic, `lvt-ignore`, and `lvt-ignore-attrs`); server value wins. Client strips it after processing; server re-sends it each render | `` | ### Identity Attributes @@ -1191,5 +1214,5 @@ form.addEventListener('lvt:pending', (e) => { - **[Go API Reference](https://pkg.go.dev/github.com/livetemplate/livetemplate)** - Server-side API - **[Error Handling Reference](/reference/error-handling)** - Validation, error display, client-side handling - **[Template Support Matrix](/reference/template-support-matrix)** - Supported Go template features -- **[Architecture](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/design/ARCHITECTURE.md)** - System architecture +- **[Architecture](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/design/ARCHITECTURE.md)** - System architecture - **[Contributing Guide](/contributing/livetemplate)** - How to contribute diff --git a/content/reference/configuration.md b/content/reference/configuration.md index 231d68f..bb8fe47 100644 --- a/content/reference/configuration.md +++ b/content/reference/configuration.md @@ -2,8 +2,8 @@ title: "LiveTemplate Configuration Guide" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/CONFIGURATION.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # LiveTemplate Configuration Guide @@ -425,6 +425,6 @@ if err := envConfig.Validate(); err != nil { ## See Also -- [ROADMAP.md](https://github.com/livetemplate/livetemplate/blob/v0.22.0/ROADMAP.md) - Project roadmap +- [ROADMAP.md](https://github.com/livetemplate/livetemplate/blob/v0.23.0/ROADMAP.md) - Project roadmap - [OBSERVABILITY.md](/guides/observability) - Logging and metrics guide - [SCALING.md](/guides/scaling) - Scaling recommendations diff --git a/content/reference/controller-pattern.md b/content/reference/controller-pattern.md index ae2a839..f7b880d 100644 --- a/content/reference/controller-pattern.md +++ b/content/reference/controller-pattern.md @@ -2,8 +2,8 @@ title: "Controller+State Pattern Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/controller-pattern.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Controller+State Pattern Reference @@ -452,7 +452,7 @@ func (c *NotificationController) AddMessage(state NotificationState, ctx *livete } ``` -> `TriggerAction` is also the mechanism behind the server-owned loading pattern (set `Loading=true`, spawn a goroutine, trigger a second action to clear it). See [Loading States §7.3](/guides/progressive-complexity#73-server-owned-loading-tier-1) in the Progressive Complexity Guide. +> `TriggerAction` is the mechanism *behind* server-owned loading, but you rarely write it by hand for that: [`Async`](/reference/api#async) collapses the set-`Loading`/spawn/trigger-a-second-action dance into one method. Reach for `TriggerAction` directly when the work starts somewhere `Async` cannot run (`Mount`, `OnConnect`, upload handlers) or reports progress repeatedly rather than completing once. See [Loading States §7.3](/guides/progressive-complexity#73-server-owned-loading-tier-1) in the Progressive Complexity Guide. ### Cross-Tab Updates with Subscribe + Publish diff --git a/content/reference/error-handling.md b/content/reference/error-handling.md index 7bb9fba..1e54026 100644 --- a/content/reference/error-handling.md +++ b/content/reference/error-handling.md @@ -2,8 +2,8 @@ title: "Error Handling Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/error-handling.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Error Handling Reference diff --git a/content/reference/limitations.md b/content/reference/limitations.md index f4105f8..57d612c 100644 --- a/content/reference/limitations.md +++ b/content/reference/limitations.md @@ -2,13 +2,13 @@ title: "Current Limitations" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/current-limitations.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Current Limitations -Known limitations of LiveTemplate, organized by category. Each entry includes the impact, workaround, and current status. For planned improvements, see the [Roadmap](https://github.com/livetemplate/livetemplate/blob/v0.22.0/ROADMAP.md). +Known limitations of LiveTemplate, organized by category. Each entry includes the impact, workaround, and current status. For planned improvements, see the [Roadmap](https://github.com/livetemplate/livetemplate/blob/v0.23.0/ROADMAP.md). All limitations verified against the current codebase. @@ -27,7 +27,7 @@ These Go template constructs trigger a fallback to HTML segmentation, which prod | `{{block}}` with dynamic template names | Use `{{template "name" .}}` with static names | By design (fallback) | | `iter.Seq` ranges | Collect iterator to slice before passing to template | Blocked on Go templates | -See [HTML Fallback Coverage](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/roadmap/html-fallback-coverage.md) for test coverage details and [Template Support Matrix](/reference/template-support-matrix) for full Go template feature support. +See [HTML Fallback Coverage](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/roadmap/html-fallback-coverage.md) for test coverage details and [Template Support Matrix](/reference/template-support-matrix) for full Go template feature support. --- @@ -54,7 +54,7 @@ See the [Transport Compatibility table](/reference/progressive-complexity#transp |-----------|--------|-----------| | JSON serialization overhead | State is cloned via JSON marshal/unmarshal per session | Keep state structs small; avoid large nested structures | | State must be JSON-serializable | Functions, channels, and unexported fields cannot be in state | Put non-serializable dependencies in the controller | -| Dependency detection is heuristic | `AsState[T]()` only catches 9 known dependency patterns (stdlib + `*redis.Client`) | Add `AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/references/t)` to test files for stricter validation | +| Dependency detection is heuristic | `AsState[T]()` only catches 9 known dependency patterns (stdlib + `*redis.Client`) | Add `AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/references/t)` to test files for stricter validation | See [Session Reference — State Safety](/reference/session#state-safety) for the full enforcement architecture. @@ -102,14 +102,14 @@ Use `ctx.IsHTTP()` to check which transport is active in an action method. | State cloning JSON round-trip | Per-session cost on first request | Keep state small; subsequent renders are fast (~3 KB, 61 allocs) | | HTML fallback parsing (3.05% of allocations) | Triggered by unsupported template constructs (see Template Features above) | Improve template construct coverage to reduce fallback frequency | -See [Known Bottlenecks](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/performance/known-bottlenecks.md) for detailed profiling data and optimization history. +See [Known Bottlenecks](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/performance/known-bottlenecks.md) for detailed profiling data and optimization history. --- ## See Also -- [Roadmap](https://github.com/livetemplate/livetemplate/blob/v0.22.0/ROADMAP.md) — Planned improvements and feature timeline +- [Roadmap](https://github.com/livetemplate/livetemplate/blob/v0.23.0/ROADMAP.md) — Planned improvements and feature timeline - [Session Reference — State Safety](/reference/session#state-safety) — Enforcement layers for state purity and session isolation - [Template Support Matrix](/reference/template-support-matrix) — Supported Go template features -- [HTML Fallback Coverage](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/roadmap/html-fallback-coverage.md) — Fallback trigger test coverage -- [Known Bottlenecks](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/performance/known-bottlenecks.md) — Performance profiling and optimization +- [HTML Fallback Coverage](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/roadmap/html-fallback-coverage.md) — Fallback trigger test coverage +- [Known Bottlenecks](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/performance/known-bottlenecks.md) — Performance profiling and optimization diff --git a/content/reference/navigate.md b/content/reference/navigate.md index 8ac005e..b45e186 100644 --- a/content/reference/navigate.md +++ b/content/reference/navigate.md @@ -2,8 +2,8 @@ title: "Navigate Action Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/navigate.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Navigate Action Reference @@ -121,7 +121,7 @@ The load-bearing test is `TestNavigateActionReMountsWithNewQueryData` in `naviga 3. Sends `{action: "__navigate__", data: {s: "beta"}}` over the same WS. 4. Confirms the next render flips `Selected` to `"beta"` and bumps `MountCount` to `2` — proving Mount re-ran without any reconnect. -Browser-level chromedp tests live in the lvt repo at `e2e/livetemplate_core_test.go` per the [test strategy](https://github.com/livetemplate/livetemplate/blob/v0.22.0/CLAUDE.md). Both layers must stay green. +Browser-level chromedp tests live in the lvt repo at `e2e/livetemplate_core_test.go` per the [test strategy](https://github.com/livetemplate/livetemplate/blob/v0.23.0/CLAUDE.md). Both layers must stay green. --- diff --git a/content/reference/progressive-complexity.md b/content/reference/progressive-complexity.md index 5d7d950..d5ee24e 100644 --- a/content/reference/progressive-complexity.md +++ b/content/reference/progressive-complexity.md @@ -2,8 +2,8 @@ title: "Progressive Complexity Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/progressive-complexity-reference.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Progressive Complexity Reference diff --git a/content/reference/pubsub.md b/content/reference/pubsub.md index f58da39..625a125 100644 --- a/content/reference/pubsub.md +++ b/content/reference/pubsub.md @@ -2,8 +2,8 @@ title: "PubSub Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/pubsub.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # PubSub Reference @@ -160,7 +160,7 @@ LiveTemplate uses two independent isolation models: ### Session Isolation (State Boundaries) -Handled by the session store and connection registry. All connections with the same `groupID` share the same state instance. Different groups have completely separate state. This is unaffected by pubsub. See [Multi-Session Isolation](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/design/multi-session-isolation.md) for details. +Handled by the session store and connection registry. All connections with the same `groupID` share the same state instance. Different groups have completely separate state. This is unaffected by pubsub. See [Multi-Session Isolation](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/design/multi-session-isolation.md) for details. ### Message Routing Isolation (PubSub) @@ -265,6 +265,6 @@ Grep for `event=topic_action_subscribe_failed` in production logs (the structure - [Server Actions Reference](/reference/server-actions) — `TriggerAction` API - [Session Reference](/reference/session) — Session stores and connection management -- [Multi-Session Isolation](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/design/multi-session-isolation.md) — State isolation model +- [Multi-Session Isolation](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/design/multi-session-isolation.md) — State isolation model - [Scaling Guide](/guides/scaling) — Redis configuration and scaling tiers - [Configuration Reference](/reference/configuration) — Environment variables and WebSocket settings diff --git a/content/reference/server-actions.md b/content/reference/server-actions.md index 3c0bc47..a982f62 100644 --- a/content/reference/server-actions.md +++ b/content/reference/server-actions.md @@ -2,8 +2,8 @@ title: "Server Actions Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/server-actions.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Server Actions Reference @@ -247,90 +247,53 @@ func (c *AuthController) ServerWelcome(state AuthState, ctx *livetemplate.Contex ### Background Job Completion -Notify users when async jobs finish. Use proper cleanup with context cancellation: +A job kicked off by a user action — an export, a report, a slow upstream call — should use [`Async`](/reference/api#async) rather than a hand-rolled goroutine. `Async` runs the work off the event loop and re-enters the loop to apply the result: ```go type ExportState struct { ExportStatus string + DownloadURL string } -type ExportController struct { - session livetemplate.Session - cancelExport context.CancelFunc - mu sync.Mutex -} - -func (c *ExportController) OnConnect(state ExportState, ctx *livetemplate.Context) (ExportState, error) { - c.mu.Lock() - c.session = ctx.Session() - c.mu.Unlock() - return state, nil -} - -func (c *ExportController) OnDisconnect() { - c.mu.Lock() - defer c.mu.Unlock() - - // Cancel any running export when user disconnects - if c.cancelExport != nil { - c.cancelExport() - c.cancelExport = nil - } - c.session = nil -} +type ExportController struct{} func (c *ExportController) StartExport(state ExportState, ctx *livetemplate.Context) (ExportState, error) { - // Create cancellable context for the background job - jobCtx, cancel := context.WithCancel(context.Background()) - - c.mu.Lock() - c.cancelExport = cancel - c.mu.Unlock() - - go func() { - defer cancel() // Clean up when done - - result, err := performLongRunningExport(jobCtx) - - // Check if cancelled before notifying - select { - case <-jobCtx.Done(): - return // User disconnected, don't notify - default: - } - - c.mu.Lock() - session := c.session - c.mu.Unlock() - - if session != nil { + livetemplate.Async(ctx, + func(jobCtx context.Context) (ExportResult, error) { + return performLongRunningExport(jobCtx) // jobCtx is cancelled on disconnect + }, + func(s ExportState, result ExportResult, err error) (ExportState, error) { if err != nil { - session.TriggerAction("exportFailed", map[string]interface{}{ - "error": err.Error(), - }) - } else { - session.TriggerAction("exportComplete", map[string]interface{}{ - "downloadURL": result.URL, - }) + s.ExportStatus = "Failed: " + err.Error() + return s, nil } - } - }() + s.ExportStatus = "Complete" + s.DownloadURL = result.URL + return s, nil + }, + ) state.ExportStatus = "Processing..." - return state, nil + return state, nil // render #1; the completion render follows automatically } +``` -func (c *ExportController) ExportComplete(state ExportState, ctx *livetemplate.Context) (ExportState, error) { - state.ExportStatus = "Complete" - state.DownloadURL = ctx.GetString("downloadURL") - return state, nil -} +That is the whole controller. `Async` supplies what the manual version had to build by hand: -func (c *ExportController) ExportFailed(state ExportState, ctx *livetemplate.Context) (ExportState, error) { - state.ExportStatus = "Failed: " + ctx.GetString("error") - return state, nil -} -``` +| Hand-rolled | Supplied by `Async` | +|---|---| +| `context.WithCancel` + a `cancelExport` field + `OnDisconnect` cleanup | `work` receives a context already bound to the connection's lifetime | +| A `select` on `jobCtx.Done()` before notifying | If the connection closed, `apply` never runs | +| A `session` field, an `OnConnect` to capture it, and a `sync.Mutex` guarding both | `apply` runs on the event loop against the current state | +| `exportComplete` / `exportFailed` actions and their two handler methods | The `err` parameter of `apply` | + +**When you still need the manual pattern.** `Async` is scoped to action handlers and is one-shot — one `work`, one `apply`. Keep a goroutine plus `TriggerAction` when the job: + +- **reports progress repeatedly** (a percentage, a streaming log) rather than completing once; +- **must outlive the connection** — `Async` cancels its work on disconnect, so a job that has to finish server-side regardless needs its own lifetime and should notify through a topic; +- **starts outside an action handler**, e.g. re-spawned from `OnConnect` after a reconnect. See [Recovery contract](#recovery-contract-idempotent-handlers--onconnect-re-spawn). + +In those cases capture the session in `OnConnect` under a mutex, cancel from `OnDisconnect`, and check `jobCtx.Done()` before notifying — the shape the table above replaces. ### Real-time Notifications (legacy `sync.Map` pattern) @@ -541,7 +504,7 @@ buffer or replay it. The cookie-bound `groupID` is stable across reconnects, so the *next* `TriggerAction` after the WebSocket comes back will reach the user, but the dispatch that fired during the gap is gone. -This is a deliberate design — see the [TriggerAction reconnect-buffering proposal](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/proposals/triggeraction-reconnect-buffering.md). +This is a deliberate design — see the [TriggerAction reconnect-buffering proposal](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/proposals/triggeraction-reconnect-buffering.md). ### Detecting the gap @@ -622,7 +585,7 @@ Two rules cover the gap: 1. **Push handlers must be idempotent.** A handler that runs once must produce the same final state as one that runs twice. The - [reconnect-during-loading double-fire race documented under Implementation Notes in `patterns.md`](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/proposals/patterns.md#implementation-notes-accumulated-from-completed-sessions) + [reconnect-during-loading double-fire race documented under Implementation Notes in `patterns.md`](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/proposals/patterns.md#implementation-notes-accumulated-from-completed-sessions) makes this concrete: if the client disconnects and reconnects while a goroutine is still sleeping, two goroutines may race to dispatch — both land successfully on the new connection. Idempotent handlers absorb @@ -707,7 +670,7 @@ once-only audit log, paid-API result stream, etc.) the implicit contract is not enough. Open a new issue referencing [#342](https://github.com/livetemplate/livetemplate/issues/342) and describing the exact non-idempotency. The -[buffering proposal](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/proposals/triggeraction-reconnect-buffering.md) +[buffering proposal](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/proposals/triggeraction-reconnect-buffering.md) captures the design sketch for the durable variant that would solve it, gated on a real use case. diff --git a/content/reference/session.md b/content/reference/session.md index a42ed01..2919be8 100644 --- a/content/reference/session.md +++ b/content/reference/session.md @@ -2,8 +2,8 @@ title: "Session Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/session.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Session Reference @@ -174,7 +174,7 @@ If state contains functions, channels, or circular references, the JSON round-tr #### Layer 4: Test Helper -`AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/references/t)` runs the same validation as Layer 2 but fails the test instead of panicking: +`AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/references/t)` runs the same validation as Layer 2 but fails the test instead of panicking: ```go func TestState(t *testing.T) { @@ -190,7 +190,7 @@ Add this to every state type's test file. It catches dependency leakage in CI be |-----------|---------------|---------| | Dependency type in state struct | `AsState[T]()` at handler registration | **Panic** with field name and type | | Non-serializable field (func, chan) | First session clone at runtime | JSON marshal error | -| Dependency in test | `AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/references/t)` in test suite | Test failure with field name and type | +| Dependency in test | `AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/references/t)` in test suite | Test failure with field name and type | ### Session Isolation @@ -557,7 +557,7 @@ var ( ## Limitations -- **Dependency detection is heuristic**: Only catches 9 known dependency patterns (stdlib types like `*sql.DB` plus common third-party types like `*redis.Client`). `AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/references/t)` uses the same `validatePureState` heuristics as `AsState`—it helps you catch the *same* issues earlier in CI / tests without panicking, but it does not broaden detection. Truly unknown custom wrappers or third-party types will not be flagged unless you extend the framework's pattern list or add custom validation. +- **Dependency detection is heuristic**: Only catches 9 known dependency patterns (stdlib types like `*sql.DB` plus common third-party types like `*redis.Client`). `AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/references/t)` uses the same `validatePureState` heuristics as `AsState`—it helps you catch the *same* issues earlier in CI / tests without panicking, but it does not broaden detection. Truly unknown custom wrappers or third-party types will not be flagged unless you extend the framework's pattern list or add custom validation. - **Warning — Session isolation depends on Authenticator**: A custom `Authenticator` that returns the same `groupID` for different users would break isolation. Use the built-in authenticators or ensure `GetSessionGroup` maps distinct users to distinct groups. - **JSON serialization overhead**: State cloning involves a JSON round-trip per session. Keep state structs small for best performance. diff --git a/content/reference/template-support-matrix.md b/content/reference/template-support-matrix.md index 22a80b3..ee84c85 100644 --- a/content/reference/template-support-matrix.md +++ b/content/reference/template-support-matrix.md @@ -2,8 +2,8 @@ title: "LiveTemplate Go Template Support Matrix" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/template-support-matrix.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # LiveTemplate Go Template Support Matrix diff --git a/content/reference/uploads.md b/content/reference/uploads.md index 52423c7..8479966 100644 --- a/content/reference/uploads.md +++ b/content/reference/uploads.md @@ -2,8 +2,8 @@ title: "Upload Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/uploads.md" -source_ref: "v0.22.0" -source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" +source_ref: "v0.23.0" +source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6" --- # Upload Reference