Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions content/contributing/livetemplate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions content/guides/ephemeral-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions content/guides/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
160 changes: 157 additions & 3 deletions content/guides/progressive-complexity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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** (`<fieldset>` + 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
Expand All @@ -252,7 +265,148 @@ During form submission, the framework automatically:
</style>
```

No `lvt-*` attributes needed. The `<fieldset>` wrapping is the signal.
No `lvt-*` attributes needed, no Go code. The `<fieldset>` 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
<button name="save"
lvt-el:toggleAttr:on:pending="disabled"
lvt-el:addClass:on:pending="opacity-50"
lvt-el:removeClass:on:done="opacity-50">
Save
</button>
```

```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
<button name="greet" {{if .Loading}}disabled{{end}}>
{{if .Loading}}Loading...{{else}}Greet{{end}}
</button>
```

**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
<button name="greet" {{if .lvt.Pending}}disabled{{end}}>
{{if .lvt.Pending}}Loading...{{else}}Greet{{end}}
</button>
```

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.

---

Expand Down
4 changes: 2 additions & 2 deletions content/guides/scaling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions content/guides/standard-html-reactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 82 additions & 2 deletions content/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
<button name="greet" {{if .lvt.Pending}}disabled{{end}}>
{{if .lvt.Pending}}Loading...{{else}}Greet{{end}}
</button>
```

See [Loading States §7.3](../guides/progressive-complexity.md#73-server-owned-loading-tier-1)
for the full comparison of loading approaches.

---

## LiveHandler

```go
Expand Down
4 changes: 2 additions & 2 deletions content/reference/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions content/reference/client-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -403,6 +403,8 @@ These execute client-side with no server round-trip.
</button>
```

> 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
Expand Down
4 changes: 2 additions & 2 deletions content/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions content/reference/controller-pattern.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading