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: 1 addition & 3 deletions content/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ _Canonical source: [livetemplate/livetemplate/CHANGELOG.md](https://github.com/l

### Features

- simplify state management — remove WithSharedState/WithStatePersistence, add Sync() lifecycle ([#298](https://github.com/livefir/livetemplate/issues/298))
- simplify state management and persistence defaults ([#298](https://github.com/livefir/livetemplate/issues/298))
- make state persistence opt-in via WithStatePersistence() ([#295](https://github.com/livefir/livetemplate/issues/295))
- per-connection state persists to session store for page refresh ([#290](https://github.com/livefir/livetemplate/issues/290))

Expand Down Expand Up @@ -224,14 +224,12 @@ _Canonical source: [livetemplate/livetemplate/CHANGELOG.md](https://github.com/l


actions no longer auto-broadcast state or persist to SessionStore.
Use WithSharedState() to restore the old behavior for backward compatibility.

Key changes:
- Remove auto-broadcast and SessionStore persist from WebSocket action loop
- Add ctx.BroadcastAction() API for explicit cross-connection dispatch
- Restructure WS message loop to select-based event loop (readPump + DispatchChan)
- Add GroupActionMessage type and Redis PubSub support for cross-instance broadcast
- Add WithSharedState() option for backward compatibility
- Handle BroadcastAction from both WebSocket and HTTP POST paths


Expand Down
2 changes: 1 addition & 1 deletion content/getting-started/your-first-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,5 @@ You wrote a counter that:
- [How a LiveTemplate Update Flows](/recipes/architecture-flow) — the sequence diagram of what happened between your click and the DOM patch.
- [UI pattern recipes](/recipes/ui-patterns/) — 33 live, reactive UI idioms you can copy. Forms, lists, search, real-time, navigation, feedback.
- [Server API reference](/reference/api) — `New`, `Handle`, `Context`, action method dispatch.
- [Sync, Broadcast & Multi-User Sessions](/recipes/sync-and-broadcast) — when `Sync()` vs `BroadcastAction()`, and how sessions are scoped.
- [Broadcast & Server Push](/recipes/sync-and-broadcast) — when to use `BroadcastAction()` vs `TriggerAction()`, and how sessions are scoped.
- [App recipes](/recipes/apps/) — runnable apps including chat, todos, file uploads, auth.
4 changes: 2 additions & 2 deletions content/guides/ephemeral-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,13 @@ func (c *Controller) OnConnect(state AppState, ctx *livetemplate.Context) (AppSt
return state, nil
}

func (c *Controller) Sync(state AppState, ctx *livetemplate.Context) (AppState, error) {
func (c *Controller) Refresh(state AppState, ctx *livetemplate.Context) (AppState, error) {
state = initComponents(state)
return state, nil
}
```

All three hooks must call `initComponents` because non-persistent fields (like `*toast.Container`) are nil after deserialization. Missing any hook causes a nil-pointer panic on that code path.
Any action that can run after state has been deserialized or independently dispatched must call `initComponents` because non-persistent fields (like `*toast.Container`) may be nil on that code path.

### Adding Messages

Expand Down
2 changes: 1 addition & 1 deletion content/recipes/apps/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Runnable application recipes demonstrating LiveTemplate usage with various featu

The todo app demonstrates LiveTemplate's core features in ~150 lines of Go + ~80 lines of HTML:

- **Real-time sync** — open two tabs as the same user; changes appear instantly via `Sync()`
- **Real-time sync** — open two tabs as the same user; changes appear instantly via explicit `BroadcastAction`
- **Standard HTML forms** — `<form method="POST" name="add">` routes to `Add()` with zero configuration
- **Live search & sort** — `Change()` auto-wires input events with 300ms debounce
- **Validation** — `ErrorTag`, `AriaInvalid`, `AriaDisabled` template helpers
Expand Down
2 changes: 1 addition & 1 deletion content/recipes/architecture-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Open browser DevTools → Network → WS to watch the WebSocket frames flow. The

## What you can change to see this in action

Open the [todos example](/recipes/apps/todos) in two browser tabs. Add an item in tab 1 — it appears in tab 2 within ~30ms because the controller calls `ctx.Sync()` on every mutation, broadcasting the patch frame to every connected session for the same controller.
Open the [todos example](/recipes/apps/todos) in two browser tabs. Add an item in tab 1 — it appears in tab 2 within ~30ms because the controller calls `ctx.BroadcastAction("RefreshTodos", nil)` after each shared mutation.

## How this page works

Expand Down
2 changes: 1 addition & 1 deletion content/recipes/counter/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,5 @@ The `embed.FS` + temp-file dance at the top is a workaround for `livetemplate.Wi
- [Reference — Authentication](/reference/authentication) — the full `Authenticator` interface, beyond the anonymous default.
- [Reference — PubSub & Broadcasting](/reference/pubsub) — multi-instance broadcasting via Redis.
- [Reference — Server Actions](/reference/server-actions) — the action lifecycle, including `BroadcastAction` ordering rules and gotchas.
- [Sync, Broadcast & Multi-User Sessions](/recipes/sync-and-broadcast) — when `Sync()` is enough and when you need broadcast.
- [Broadcast & Server Push](/recipes/sync-and-broadcast) — when to use explicit broadcast and server push.
- [Your First App](/getting-started/your-first-app) — if you arrived here cold, the from-scratch walkthrough is the better starting point.
2 changes: 1 addition & 1 deletion content/recipes/patterns/_app/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ func allPatterns() []PatternCategory {
{
Name: "Real-Time & Multi-User",
Patterns: []PatternLink{
{Name: "Multi-User Sync", Path: "/patterns/realtime/multi-user-sync", Description: "Auto-sync across tabs via Sync() handler", Implemented: true},
{Name: "Multi-User Refresh", Path: "/patterns/realtime/multi-user-sync", Description: "Explicit peer refresh via BroadcastAction", Implemented: true},
{Name: "Broadcasting", Path: "/patterns/realtime/broadcasting", Description: "Cross-connection updates via BroadcastAction", Implemented: true},
{Name: "Presence Tracking", Path: "/patterns/realtime/presence", Description: "Explicit join/leave with shared state", Implemented: true},
{Name: "Reconnection Recovery", Path: "/patterns/realtime/reconnection", Description: "State persistence across disconnects", Implemented: true},
Expand Down
2 changes: 1 addition & 1 deletion content/recipes/patterns/_app/handlers_lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func infiniteScrollHandler() http.Handler {

// --- Sortable List ---

// SortableController holds the list ordering process-wide so it persists across reloads (live multi-tab sync would need Sync()).
// SortableController holds the list ordering process-wide so it persists across reloads (live multi-tab sync would need BroadcastAction).
type SortableController struct {
mu sync.Mutex
items []SortableItem
Expand Down
15 changes: 7 additions & 8 deletions content/recipes/patterns/_app/handlers_realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"github.com/livetemplate/livetemplate"
)

// --- Pattern #26: Multi-User Sync ---
// --- Pattern #26: Multi-User Refresh ---

type MultiUserSyncController struct {
mu sync.RWMutex
Expand All @@ -19,20 +19,18 @@ type MultiUserSyncController struct {

// Mount runs on every initial render. Without it, a tab that opens
// AFTER other tabs have incremented would render Counter:0 and only
// converge on the next peer action's Sync. Same fix as PresenceController.
// converge on the next peer refresh. Same fix as PresenceController.
func (c *MultiUserSyncController) Mount(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) {
c.mu.RLock()
state.Counter = c.counter
c.mu.RUnlock()
return state, nil
}

// Sync is a reserved method name (livetemplate/mount.go:114). The framework
// auto-dispatches it to peer connections in the same session group after any
// action completes — Increment doesn't need to call BroadcastAction. The state
// arg is the peer's local state; we replace its Counter from the shared
// controller value so all tabs converge.
func (c *MultiUserSyncController) Sync(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) {
// RefreshCounter is explicitly broadcast to peer connections after Increment.
// The state arg is the peer's local state; we replace its Counter from the
// shared controller value so all tabs converge.
func (c *MultiUserSyncController) RefreshCounter(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) {
c.mu.RLock()
state.Counter = c.counter
c.mu.RUnlock()
Expand All @@ -44,6 +42,7 @@ func (c *MultiUserSyncController) Increment(state MultiUserSyncState, ctx *livet
c.counter++
state.Counter = c.counter
c.mu.Unlock()
ctx.BroadcastAction("RefreshCounter", nil)
return state, nil
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{{define "content"}}
<article>
<h3>Multi-User Sync</h3>
<p><small>The reserved <code>Sync()</code> method on a controller is auto-dispatched to peer connections in the same session group after any action completes — no explicit <code>BroadcastAction</code> call is needed. Both tabs read the same shared counter from the controller's mutex-protected state.</small></p>
<h3>Multi-User Refresh</h3>
<p><small><code>Increment</code> updates the shared counter, then explicitly calls <code>BroadcastAction("RefreshCounter", nil)</code> so peer connections reload from the controller's mutex-protected state.</small></p>

<p>Counter: <strong>{{.Counter}}</strong></p>

<form method="POST">
<button name="increment">Increment</button>
</form>

<p><small><strong>Try:</strong> Open this page in a second tab. Click Increment in either tab — both stay in sync because the framework dispatches <code>Sync()</code> to peers on every action's render.</small></p>
<p><small><strong>Try:</strong> Open this page in a second tab. Click Increment in either tab — both stay in sync because the action explicitly broadcasts a peer refresh.</small></p>
</article>
{{end}}
90 changes: 39 additions & 51 deletions content/recipes/sync-and-broadcast.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
---
title: "Sync, Broadcast & Multi-User Sessions"
title: "Broadcast & Server Push"
source_repo: https://github.com/livetemplate/docs
source_path: content/recipes/sync-and-broadcast.md
---

# Sync, Broadcast & Multi-User Sessions
# Broadcast & Server Push

The [single-action flow recipe](/recipes/architecture-flow) covers what happens when one user clicks one button. This page covers **what happens when there are many users, many tabs, many sessions** — and how the framework keeps them coherent without you writing diffing or messaging code.

## The two propagation mechanisms

LiveTemplate exposes two ways for one user's action to update other connected viewers:

- **`ctx.Sync()`** — re-renders the **same controller for every active session of the same user**. The canonical use: a user has the app open in two tabs and edits a row in tab 1; tab 2 updates within ~30ms.
- **`ctx.BroadcastAction(...)`** — re-renders the **same controller across DIFFERENT users** subscribed to the same broadcast channel. The canonical use: a public chat room where messages from any user appear for every viewer.
- **`ctx.BroadcastAction(...)`** — after the current action succeeds, dispatches a named action to peer connections in the same session group. The canonical use: a user has the app open in two tabs and edits a row in tab 1; tab 2 runs a `Refresh...` action and updates.
- **`session.TriggerAction(...)`** — dispatches a named action from server-owned work such as goroutines, timers, subscriptions, or background job callbacks.

Both reuse the same diff-and-patch pipeline as a single-user action; the difference is just **whose connections receive the patch frame**.
Both reuse the same diff-and-patch pipeline as a single-user action; the difference is **when the action is enqueued and which connections receive it**.

## Sync — same user, multiple tabs
## BroadcastAction — same session group, peer tabs

```mermaid
sequenceDiagram
Expand All @@ -29,60 +29,48 @@ sequenceDiagram

Note over TabA,TabB: Same user, two tabs, both connected to /todos
TabA->>Server: WS: action "Save", form data
Server->>Server: Mutate state.Items
Server->>Server: ctx.Sync() — broadcast to my own sessions
Server->>Sessions: Find every session for this user + controller
Sessions-->>Server: [Tab A connection, Tab B connection]
Server-->>TabA: WS: patches [...]
Server-->>TabB: WS: patches [...]
Server->>Server: Save row, update Tab A state
Server->>Server: ctx.BroadcastAction("RefreshTodos", nil)
Server->>Sessions: Find peer connections in this group
Sessions-->>Server: [Tab B connection]
Server-->>TabA: WS: Save response patch
Server-->>TabB: WS: RefreshTodos patch
Note over TabA,TabB: Both tabs render the same new state. <br/>Tab B never made an HTTP request.
```

Code shape:

```go
func (c *TodosController) Save(state *State, ctx *livetemplate.Context) error {
state.Items = append(state.Items, Item{Title: ctx.GetString("title")})
return ctx.Sync()
func (c *TodosController) Save(state State, ctx *livetemplate.Context) (State, error) {
c.DB.Save(ctx.UserID(), ctx.GetString("title"))
state.Items = c.DB.List(ctx.UserID())
ctx.BroadcastAction("RefreshTodos", nil)
return state, nil
}
```

## BroadcastAction — different users, shared channel

```mermaid
sequenceDiagram
autonumber
participant Alice
participant Bob
participant Server as Go server
participant Channel as "chat-room-1" channel

Note over Alice,Bob: Two users, both subscribed to chat-room-1
Alice->>Server: WS: action "Send", message="hi"
Server->>Server: Append to room.Messages
Server->>Channel: ctx.BroadcastAction("chat-room-1", "Refresh")
Channel-->>Server: Subscribers: [Alice's session, Bob's session]
Server-->>Alice: WS: patches [..]
Server-->>Bob: WS: patches [..]
Note over Alice,Bob: Bob's tab updates without Bob doing anything.<br/>His controller's "Refresh" action ran, server-side, on his behalf.
func (c *TodosController) RefreshTodos(state State, ctx *livetemplate.Context) (State, error) {
state.Items = c.DB.List(ctx.UserID())
return state, nil
}
```

Code shape:
## TriggerAction — server push

```go
func (c *ChatController) OnConnect(state *State, ctx *livetemplate.Context) error {
return ctx.Subscribe("chat-room-" + state.RoomID)
}
Use `ctx.Session()` when a controller starts work that will finish later:

func (c *ChatController) Send(state *State, ctx *livetemplate.Context) error {
state.Messages = append(state.Messages, Message{Body: ctx.GetString("body")})
return ctx.BroadcastAction("chat-room-"+state.RoomID, "Refresh")
```go
func (c *Controller) OnConnect(state State, ctx *livetemplate.Context) (State, error) {
session := ctx.Session()
go func() {
result := fetchSlowData()
_ = session.TriggerAction("DataLoaded", map[string]any{"value": result})
}()
return state, nil
}

func (c *ChatController) Refresh(state *State, ctx *livetemplate.Context) error {
// Re-fetch from canonical store if needed, then return.
// The patch pipeline runs automatically.
return nil
func (c *Controller) DataLoaded(state State, ctx *livetemplate.Context) (State, error) {
state.Value = ctx.GetString("value")
return state, nil
}
```

Expand All @@ -106,14 +94,14 @@ Click `+1` in either widget; the other moves at the same time. The `session=` at

| Need | Use |
|---|---|
| Same logged-in user, multi-tab coherence | `ctx.Sync()` — implicit, no channel name needed |
| Different users seeing the same shared state | `ctx.BroadcastAction(channel, action)` — channel is your shard key |
| One-shot push from a background goroutine (no user action triggered it) | `controller.Push(channel, action)` from anywhere |
| A user action should update peer tabs after it succeeds | `ctx.BroadcastAction("Refresh...", nil)` |
| A background goroutine/timer/job should push to live connections | `session.TriggerAction("...", data)` |
| The current connection should update from its own action | Return the new state from the action |

You almost always want `Sync()` for personal app interactions and `BroadcastAction` for collaborative / public state.
Nothing crosses connections implicitly. If another connection should update, the action says so.

## How this page works

Two `mermaid` sequence-diagram blocks render client-side via tinkerdown's bundled mermaid runtime. The diagrams live next to the code shapes they describe, so changing the code is a same-file edit — no out-of-tree diagram tool, no PNG that goes stale.

For runnable examples, see the [chat example](/recipes/apps/chat) and the patterns under [Real-Time](/recipes/ui-patterns/) (Multi-User Sync, Broadcasting, Presence Tracking).
For runnable examples, see the [chat example](/recipes/apps/chat) and the patterns under [Real-Time](/recipes/ui-patterns/) (Multi-User Refresh, Broadcasting, Presence Tracking).
34 changes: 27 additions & 7 deletions content/recipes/todos/_app/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func (c *TodoController) OnConnect(state TodoState, ctx *livetemplate.Context) (
return c.loadTodos(context.Background(), state, ctx.UserID())
}

func (c *TodoController) Sync(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
func (c *TodoController) RefreshTodos(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state = initComponents(state)
return c.loadTodos(context.Background(), state, ctx.UserID())
}
Expand Down Expand Up @@ -56,7 +56,12 @@ func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoSt

state.Toasts.AddSuccess("Added", fmt.Sprintf("%q added", input.Text))
state.LastUpdated = formatTime()
return c.loadTodos(dbCtx, state, ctx.UserID())
state, err = c.loadTodos(dbCtx, state, ctx.UserID())
if err != nil {
return state, err
}
ctx.BroadcastAction("RefreshTodos", nil)
return state, nil
}

func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
Expand Down Expand Up @@ -90,7 +95,12 @@ func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (Tod
state.Toasts.AddInfo("Reopened", "Todo marked as incomplete")
}
state.LastUpdated = formatTime()
return c.loadTodos(dbCtx, state, ctx.UserID())
state, err = c.loadTodos(dbCtx, state, ctx.UserID())
if err != nil {
return state, err
}
ctx.BroadcastAction("RefreshTodos", nil)
return state, nil
}

// ConfirmDelete shows the delete confirmation modal for the given todo ID.
Expand Down Expand Up @@ -120,7 +130,12 @@ func (c *TodoController) ConfirmDeleteConfirm(state TodoState, ctx *livetemplate
state.DeleteConfirm.Hide()
state.DeleteID = ""
state.LastUpdated = formatTime()
return c.loadTodos(dbCtx, state, ctx.UserID())
state, err = c.loadTodos(dbCtx, state, ctx.UserID())
if err != nil {
return state, err
}
ctx.BroadcastAction("RefreshTodos", nil)
return state, nil
}

// CancelDeleteConfirm dismisses the delete confirmation modal.
Expand Down Expand Up @@ -206,7 +221,12 @@ func (c *TodoController) ClearCompleted(state TodoState, ctx *livetemplate.Conte

state.Toasts.AddSuccess("Cleared", fmt.Sprintf("%d completed todo(s) removed", state.CompletedCount))
state.LastUpdated = formatTime()
return c.loadTodos(dbCtx, state, ctx.UserID())
state, err = c.loadTodos(dbCtx, state, ctx.UserID())
if err != nil {
return state, err
}
ctx.BroadcastAction("RefreshTodos", nil)
return state, nil
}

func (c *TodoController) loadTodos(ctx context.Context, state TodoState, userID string) (TodoState, error) {
Expand All @@ -215,7 +235,7 @@ func (c *TodoController) loadTodos(ctx context.Context, state TodoState, userID
return state, fmt.Errorf("failed to load todos: %w", err)
}

// Refresh on every render path (Mount/OnConnect/Sync + every action)
// Refresh on every render path (Mount/OnConnect/RefreshTodos + every action)
// so the footer's "Last updated" line reflects this session, not the
// handler's process-start time.
state.LastUpdated = formatTime()
Expand Down Expand Up @@ -248,7 +268,7 @@ func (c *TodoController) loadTodos(ctx context.Context, state TodoState, userID
}

// initComponents initializes non-serializable component objects.
// Called from Mount/OnConnect/Sync since components can't survive serialization.
// Called from Mount/OnConnect/RefreshTodos since components can't survive serialization.
func initComponents(state TodoState) TodoState {
if state.Toasts == nil {
toasts := toast.New("notifications",
Expand Down
Loading
Loading