diff --git a/content/contributing/livetemplate.md b/content/contributing/livetemplate.md index 045eff6..e4a5567 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Contributing to LiveTemplate Core Library diff --git a/content/guides/ephemeral-components.md b/content/guides/ephemeral-components.md index bdbc0f0..457c064 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Ephemeral Components Guide diff --git a/content/guides/observability.md b/content/guides/observability.md index 3c4d5c3..32b4983 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # LiveTemplate Observability Guide diff --git a/content/guides/progressive-complexity.md b/content/guides/progressive-complexity.md index bae8b78..748a109 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Progressive Complexity Guide @@ -230,6 +230,19 @@ The framework fetches the page via `fetch()`, extracts the wrapper content, and ## 7. Loading States +LiveTemplate offers three loading models. Pick the simplest one that fits your use case: + +| I want… | Accepts `lvt-*` attrs? | Path | Go boilerplate | +|---|---|---|---| +| 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 | + +**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. + +### 7.1 Automatic Form Loading (Tier 1) + During form submission, the framework automatically: 1. Sets `aria-busy="true"` on the form @@ -252,7 +265,148 @@ During form submission, the framework automatically: ``` -No `lvt-*` attributes needed. The `
` wrapping is the signal. +No `lvt-*` attributes needed, no Go code. The `
` wrapping is the signal. This covers the "grey out the whole form" case. For custom loading UX beyond grey-out, see 7.2 or 7.3. + +### 7.2 Client-Owned Pending (Tier 2) + +When you need custom loading UX (spinners, text changes, visual feedback) and are willing to use `lvt-*` attributes, `lvt-el:*:on:pending` gives you instant feedback with zero Go code: + +```html + +``` + +```go +// Just the business logic — no loading scaffolding +func (c *Controller) Save(state State, ctx *livetemplate.Context) (State, error) { + time.Sleep(700 * time.Millisecond) // simulate slow work + state.Name = ctx.GetString("Name") + return state, nil +} +``` + +The `pending` state fires instantly on click (before the server even receives the message) and clears on `done`. This is the most concise option for custom loading UX. + +**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. + +See the [Client Attributes Reference — Reactive Attributes](../references/client-attributes.md#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: + +```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")) + 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 +} +``` + +```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). + +**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). + +#### Simplified with `Async` + +`livetemplate.Async` collapses the two-method pattern to one method (~7 lines) by handling the goroutine, dispatch channel, and re-entry automatically: + +```go +func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error) { + state.Loading = true + name := strings.TrimSpace(ctx.GetString("name")) + 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 + s.Loading = false + return s, nil + }, + ) + return state, nil // render #1: Loading=true + // render #2 happens when work completes: Loading=false +} +``` + +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 + +See the [Async API reference](../references/api-reference.md#async) for the full contract. + +#### Zero-boilerplate with `{{.lvt.Pending}}` + +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): + +```go +func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error) { + 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 +} +``` + +```html + +``` + +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. --- diff --git a/content/guides/scaling.md b/content/guides/scaling.md index c963315..fca0fd5 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # LiveTemplate Scaling Guide diff --git a/content/guides/standard-html-reactivity.md b/content/guides/standard-html-reactivity.md index c1ded18..dc89292 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Standard HTML Reactivity diff --git a/content/reference/api.md b/content/reference/api.md index caa2d72..87537dd 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Go Library API Reference @@ -296,6 +296,86 @@ can be captured and used from background goroutines. See --- +## Async + +```go +func Async[S any, R any]( + ctx *Context, + work func(context.Context) (R, error), + apply func(s S, result R, err error) (S, error), +) +``` + +Runs `work` off the connection event loop, then re-enters the loop to apply +its result to the **current** session state and re-render the originating +connection. Reduces the [manual two-action loading pattern](../guides/progressive-complexity.md#73-server-owned-loading-tier-1) +from ~15 lines / 2 methods to ~7 lines / 1 method. + +- **`work`** runs in a supervised goroutine. It receives a `context.Context` + tied to the connection's lifetime (cancelled on disconnect). It must not + touch session state — only its own inputs. +- **`apply`** runs **on the event loop** against the latest state at + completion time, not a snapshot from when `work` started. Any state + changes from other actions during the async window are visible. Mutate + only the fields you own. +- If the connection closes before `work` completes, the goroutine is + cancelled and `apply` never runs. +- Render scope is **per-connection** — only the connection that called + `Async` gets the completion render. For group-wide fan-out, capture + `session := ctx.Session()` before defining `apply`, then call + `session.TriggerAction()` from within the `apply` closure (`ctx` + itself is not in scope inside `apply`). + +**Example:** + +```go +func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error) { + state.Loading = true + name := strings.TrimSpace(ctx.GetString("name")) + 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 + s.Loading = false + return s, nil + }, + ) + return state, nil // render #1: Loading=true (spinner on) + // render #2 happens automatically when work completes: Loading=false +} +``` + +**Scope:** `Async` is supported only inside **action handlers** (e.g. +`Greet`, `Save`) that run on the per-connection WebSocket event loop. +Calling `Async` in `Mount()`, `OnConnect()`, dispatched actions, server- +initiated actions, upload handlers, or HTTP POST handlers logs a warning +and drops the operation — there is no persistent connection or event loop +to re-enter. + +**`{{.lvt.Pending}}`:** A framework-provided template variable that is `true` +on the render that registered async work and `false` on all other renders. +It has **per-render** semantics: if another action or a peer dispatch +triggers a render on the same connection while async work is still in +flight, that render will see `Pending=false` (it did not register async +work). For loading indicators that must stay visible across interleaved +renders, use an explicit `Loading bool` field in your state instead. +Use `{{.lvt.Pending}}` when the loading indicator is purely visual and +single-action flows are the norm: + +```html + +``` + +See [Loading States §7.3](../guides/progressive-complexity.md#73-server-owned-loading-tier-1) +for the full comparison of loading approaches. + +--- + ## LiveHandler ```go diff --git a/content/reference/authentication.md b/content/reference/authentication.md index df0e74b..33545b3 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Authentication Reference diff --git a/content/reference/client-attributes.md b/content/reference/client-attributes.md index 60ab378..8c1b99a 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Client Attributes Reference @@ -403,6 +403,8 @@ These execute client-side with no server round-trip. ``` +> For choosing between client-owned pending (`lvt-el:*:on:pending`) and server-owned loading (`{{if .Loading}}`), see [Loading States](../guides/progressive-complexity.md#7-loading-states) in the Progressive Complexity Guide. + **Form Reset on Success:** ```html diff --git a/content/reference/configuration.md b/content/reference/configuration.md index 2762b87..3c990f6 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # LiveTemplate Configuration Guide diff --git a/content/reference/controller-pattern.md b/content/reference/controller-pattern.md index 5b62cc7..000db4e 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Controller+State Pattern Reference @@ -452,6 +452,8 @@ 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.md#73-server-owned-loading-tier-1) in the Progressive Complexity Guide. + ### Cross-Tab Updates with Subscribe + Publish Peer fan-out is opt-in. Each connection that wants to receive peer updates subscribes to a topic in `Mount`; actions that mutate shared state publish to that topic, and every subscribed peer dispatches the named action with its own state. diff --git a/content/reference/error-handling.md b/content/reference/error-handling.md index c84acf5..7292e2f 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Error Handling Reference diff --git a/content/reference/limitations.md b/content/reference/limitations.md index 0b39655..c8159b5 100644 --- a/content/reference/limitations.md +++ b/content/reference/limitations.md @@ -2,8 +2,8 @@ title: "Current Limitations" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/current-limitations.md" -source_ref: "v0.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Current Limitations diff --git a/content/reference/navigate.md b/content/reference/navigate.md index 9aa7bf5..74a4d23 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Navigate Action Reference diff --git a/content/reference/progressive-complexity.md b/content/reference/progressive-complexity.md index 1292fa1..5899169 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Progressive Complexity Reference diff --git a/content/reference/pubsub.md b/content/reference/pubsub.md index 1047f1e..028cef0 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # PubSub Reference diff --git a/content/reference/server-actions.md b/content/reference/server-actions.md index d4d1697..cd72f81 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Server Actions Reference diff --git a/content/reference/session.md b/content/reference/session.md index 0610eba..a7e0cc9 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Session Reference diff --git a/content/reference/template-support-matrix.md b/content/reference/template-support-matrix.md index 7f55c71..d424bbb 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # LiveTemplate Go Template Support Matrix diff --git a/content/reference/uploads.md b/content/reference/uploads.md index 62097e3..3945794 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.20.1" -source_commit: "830946938ebc52e735fcd1adacd4e57a0f4e38a4" +source_ref: "v0.22.0" +source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe" --- # Upload Reference