From d128bf96cbbc02e65b93b416b143786f59f0f102 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Tue, 12 May 2026 17:42:57 +0000 Subject: [PATCH] docs: document explicit broadcast refresh --- content/changelog.md | 4 +- content/getting-started/your-first-app.md | 2 +- content/guides/ephemeral-components.md | 4 +- content/recipes/apps/index.md | 2 +- content/recipes/architecture-flow.md | 2 +- content/recipes/counter/index.md | 2 +- content/recipes/patterns/_app/data.go | 2 +- .../recipes/patterns/_app/handlers_lists.go | 2 +- .../patterns/_app/handlers_realtime.go | 15 ++-- .../templates/realtime/multi-user-sync.tmpl | 6 +- content/recipes/sync-and-broadcast.md | 90 ++++++++----------- content/recipes/todos/_app/controller.go | 34 +++++-- content/recipes/todos/index.md | 10 +-- content/reference/api.md | 3 - content/reference/limitations.md | 2 +- content/reference/server-actions.md | 5 +- content/reference/session.md | 23 +++-- e2e/patterns/patterns_test.go | 11 ++- 18 files changed, 113 insertions(+), 106 deletions(-) diff --git a/content/changelog.md b/content/changelog.md index 1c3704a..0ee3d8a 100644 --- a/content/changelog.md +++ b/content/changelog.md @@ -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)) @@ -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 diff --git a/content/getting-started/your-first-app.md b/content/getting-started/your-first-app.md index b292e05..43b2487 100644 --- a/content/getting-started/your-first-app.md +++ b/content/getting-started/your-first-app.md @@ -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. diff --git a/content/guides/ephemeral-components.md b/content/guides/ephemeral-components.md index 54f5818..8d52466 100644 --- a/content/guides/ephemeral-components.md +++ b/content/guides/ephemeral-components.md @@ -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 diff --git a/content/recipes/apps/index.md b/content/recipes/apps/index.md index 70e53a8..940b260 100644 --- a/content/recipes/apps/index.md +++ b/content/recipes/apps/index.md @@ -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** — `
` routes to `Add()` with zero configuration - **Live search & sort** — `Change()` auto-wires input events with 300ms debounce - **Validation** — `ErrorTag`, `AriaInvalid`, `AriaDisabled` template helpers diff --git a/content/recipes/architecture-flow.md b/content/recipes/architecture-flow.md index 608c64c..40054a1 100644 --- a/content/recipes/architecture-flow.md +++ b/content/recipes/architecture-flow.md @@ -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 diff --git a/content/recipes/counter/index.md b/content/recipes/counter/index.md index 8c7b277..2574f9d 100644 --- a/content/recipes/counter/index.md +++ b/content/recipes/counter/index.md @@ -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. diff --git a/content/recipes/patterns/_app/data.go b/content/recipes/patterns/_app/data.go index 3cc5e65..26251e3 100644 --- a/content/recipes/patterns/_app/data.go +++ b/content/recipes/patterns/_app/data.go @@ -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}, diff --git a/content/recipes/patterns/_app/handlers_lists.go b/content/recipes/patterns/_app/handlers_lists.go index 72256e7..b1d8b57 100644 --- a/content/recipes/patterns/_app/handlers_lists.go +++ b/content/recipes/patterns/_app/handlers_lists.go @@ -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 diff --git a/content/recipes/patterns/_app/handlers_realtime.go b/content/recipes/patterns/_app/handlers_realtime.go index 94975fd..70e8cde 100644 --- a/content/recipes/patterns/_app/handlers_realtime.go +++ b/content/recipes/patterns/_app/handlers_realtime.go @@ -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 @@ -19,7 +19,7 @@ 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 @@ -27,12 +27,10 @@ func (c *MultiUserSyncController) Mount(state MultiUserSyncState, ctx *livetempl 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() @@ -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 } diff --git a/content/recipes/patterns/_app/templates/realtime/multi-user-sync.tmpl b/content/recipes/patterns/_app/templates/realtime/multi-user-sync.tmpl index 9f0cb31..5c72ff2 100644 --- a/content/recipes/patterns/_app/templates/realtime/multi-user-sync.tmpl +++ b/content/recipes/patterns/_app/templates/realtime/multi-user-sync.tmpl @@ -1,7 +1,7 @@ {{define "content"}}
-

Multi-User Sync

-

The reserved Sync() method on a controller is auto-dispatched to peer connections in the same session group after any action completes — no explicit BroadcastAction call is needed. Both tabs read the same shared counter from the controller's mutex-protected state.

+

Multi-User Refresh

+

Increment updates the shared counter, then explicitly calls BroadcastAction("RefreshCounter", nil) so peer connections reload from the controller's mutex-protected state.

Counter: {{.Counter}}

@@ -9,6 +9,6 @@ -

Try: Open this page in a second tab. Click Increment in either tab — both stay in sync because the framework dispatches Sync() to peers on every action's render.

+

Try: Open this page in a second tab. Click Increment in either tab — both stay in sync because the action explicitly broadcasts a peer refresh.

{{end}} diff --git a/content/recipes/sync-and-broadcast.md b/content/recipes/sync-and-broadcast.md index b29f866..16be633 100644 --- a/content/recipes/sync-and-broadcast.md +++ b/content/recipes/sync-and-broadcast.md @@ -1,10 +1,10 @@ --- -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. @@ -12,12 +12,12 @@ The [single-action flow recipe](/recipes/architecture-flow) covers what happens 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 @@ -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.
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.
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 } ``` @@ -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). diff --git a/content/recipes/todos/_app/controller.go b/content/recipes/todos/_app/controller.go index 2f55495..6e29b4e 100644 --- a/content/recipes/todos/_app/controller.go +++ b/content/recipes/todos/_app/controller.go @@ -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()) } @@ -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) { @@ -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. @@ -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. @@ -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) { @@ -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() @@ -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", diff --git a/content/recipes/todos/index.md b/content/recipes/todos/index.md index 10f0242..fd1b6b1 100644 --- a/content/recipes/todos/index.md +++ b/content/recipes/todos/index.md @@ -30,7 +30,7 @@ Three of those `livetemplate.With*` options carry teaching weight. Origins (`opt Every action handler in the controller filters database queries by `ctx.UserID()`. That `UserID` comes from the authenticator: `BasicAuthenticator` returns the username that authenticated the request as both the user identity *and* the session group ID. The framework guarantees: -- **Same user, multiple tabs** → same group → `Sync()` keeps tabs in sync within one logged-in user +- **Same user, multiple tabs** → same group → explicit `BroadcastAction` keeps tabs in sync within one logged-in user - **Different users on the same machine** → different groups → no leakage - **Same user across devices** → still the same group (the username is the group), so a logged-in alice on phone + laptop sees the same list @@ -86,15 +86,15 @@ This is the smallest app that exercises the full LiveTemplate idiom. A real prod |---|---|---| | Persistence | `:memory:` SQLite, lost on restart | File-backed SQLite or Postgres; daily backup | | Auth | Hardcoded alice/bob with plaintext passwords | OAuth/SSO + an `Authenticator` impl that validates session tokens | -| Multi-instance broadcast | Single Fly machine | `WithPubSubBroadcaster` (Redis) so `Sync()` reaches peer instances | +| Multi-instance broadcast | Single Fly machine | `WithPubSubBroadcaster` (Redis) so `BroadcastAction` reaches peer instances | | User registration | None | Companion endpoint + `lvt/components/form` validation | | Audit trail | None | Append-only log table; query layer logs writes | -None of those changes the recipe's *shape* — the same controller methods, the same state struct, the same components. They swap implementations, not the surface. That's the architectural payoff for the upfront ceremony of `Authenticator` + `Mount`/`OnConnect`/`Sync` + components: the apps that grow out of this recipe inherit a clean separation between deployment plumbing and the actual interaction surface. +None of those changes the recipe's *shape* — the same controller methods, the same state struct, the same components. They swap implementations, not the surface. That's the architectural payoff for the upfront ceremony of `Authenticator` + `Mount`/`OnConnect` + explicit peer refresh actions + components: the apps that grow out of this recipe inherit a clean separation between deployment plumbing and the actual interaction surface. ## What next? -- [Counter, deeper](/recipes/counter) — the same `BroadcastAction` mechanism this app uses for multi-tab `Sync()`, in isolation. +- [Counter, deeper](/recipes/counter) — the same `BroadcastAction` mechanism this app uses for multi-tab refresh, in isolation. - [Reference — Authentication](/reference/authentication) — the full `Authenticator` interface and the contracts `BasicAuthenticator` implements. - [Reference — Components](/reference/components) — the modal + toast APIs, plus the rest of `lvt/components`. -- [Sync, Broadcast & Multi-User Sessions](/recipes/sync-and-broadcast) — when `Sync()` is enough and when you need explicit broadcast. +- [Broadcast & Server Push](/recipes/sync-and-broadcast) — when to use explicit broadcast and server push. diff --git a/content/reference/api.md b/content/reference/api.md index ffa4b44..f952147 100644 --- a/content/reference/api.md +++ b/content/reference/api.md @@ -149,9 +149,6 @@ func (c *Controller) Mount(state S, ctx *livetemplate.Context) (S, error) // Called on each WebSocket connect (including reconnects) func (c *Controller) OnConnect(state S, ctx *livetemplate.Context) (S, error) -// Called on peer connections after any action in the same session group -func (c *Controller) Sync(state S, ctx *livetemplate.Context) (S, error) - // Called when a WebSocket disconnects func (c *Controller) OnDisconnect() ``` diff --git a/content/reference/limitations.md b/content/reference/limitations.md index 4d09ef3..9292c00 100644 --- a/content/reference/limitations.md +++ b/content/reference/limitations.md @@ -63,7 +63,7 @@ See [Session Reference — State Safety](session.md#state-safety) for the full e | Limitation | Detail | Workaround | |-----------|--------|-----------| -| Tabs don't auto-sync by default | Each connection owns its state independently | Implement `Sync()` on the controller for automatic cross-tab sync, or use `ctx.BroadcastAction()` for custom sync | +| Tabs don't update each other by default | Each connection owns its state independently | Use `ctx.BroadcastAction()` to explicitly refresh peer connections | | Concurrent HTTP requests serialized | Per-group mutex in HTTP mode processes one action at a time | By design — prevents data races on shared state | See [Session Reference](session.md) for session stores and connection management. diff --git a/content/reference/server-actions.md b/content/reference/server-actions.md index 357c070..2a847e5 100644 --- a/content/reference/server-actions.md +++ b/content/reference/server-actions.md @@ -419,11 +419,12 @@ When a user has multiple tabs or devices connected: ``` User clicks button in Tab 1 └─► Tab 1's action method called + └─► action may call ctx.BroadcastAction("RefreshTodos", nil) └─► Tab 1 receives update - └─► Tab 2, Tab 3 receive Sync() dispatch (if controller implements Sync) + └─► Tab 2, Tab 3 receive the explicit peer action ``` -> When the controller implements a `Sync()` method, other tabs automatically receive a Sync dispatch after each action. Without `Sync()`, use `ctx.BroadcastAction("ActionName", nil)` for explicit cross-tab sync. +> Cross-tab updates are explicit: call `ctx.BroadcastAction("ActionName", nil)` from the action that changed shared state. **Server Action (TriggerAction):** ``` diff --git a/content/reference/session.md b/content/reference/session.md index 3f8523f..a4319c0 100644 --- a/content/reference/session.md +++ b/content/reference/session.md @@ -63,23 +63,28 @@ Fields tagged with `lvt:"persist"` follow this persistence schedule. Untagged fi | Server action | Persisted (once per group) | In-memory for connection lifetime | | Page refresh | Restored from store | Zero value, loaded by Mount() | -### Sync Lifecycle Method - -When the controller implements a `Sync()` method, the framework automatically dispatches it to peer connections in the same session group after every action. This is the recommended way to keep multiple tabs in sync: +### Explicit Peer Refresh ```go -func (c *TodoController) Sync(state TodoState, ctx *livetemplate.Context) (TodoState, error) { +func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + c.DB.AddItem(ctx.UserID(), ctx.GetString("text")) + state.Items = c.DB.GetItems(ctx.UserID()) + ctx.BroadcastAction("RefreshTodos", nil) + return state, nil +} + +func (c *TodoController) RefreshTodos(state TodoState, ctx *livetemplate.Context) (TodoState, error) { state.Items = c.DB.GetItems(ctx.UserID()) - return state, nil // Peer connections reload from database + return state, nil } ``` **How it works:** - Each browser gets a unique session ID (via cookie: `livetemplate-id`) - All tabs in the same browser share this session ID (`groupID`) -- After any action, `Sync` is dispatched to all other connections in the group -- Each connection runs `Sync` with its own state, reloading from the database -- If the controller does not implement `Sync()`, no cross-tab dispatch occurs +- After a successful action, queued `BroadcastAction` calls dispatch to other connections in the group +- Each peer connection runs the named action with its own state, reloading from the database +- If the action does not call `BroadcastAction`, no cross-tab dispatch occurs ## State Safety @@ -192,7 +197,7 @@ State is deserialized fresh on each `Get()`, preventing reference sharing across #### Broadcast Scoping -Both `Sync()` auto-dispatch and explicit `BroadcastAction()` are scoped to the sender's `groupID`. The [ConnectionRegistry](#connection-registry) filters recipients via `GetByGroup(groupID)` — messages only reach connections in the same group. Different groups are never informed of each other's updates. +`BroadcastAction()` is scoped to the sender's `groupID`. The [ConnectionRegistry](#connection-registry) filters recipients via `GetByGroup(groupID)` — messages only reach connections in the same group. Different groups are never informed of each other's updates. #### HTTP Request Isolation diff --git a/e2e/patterns/patterns_test.go b/e2e/patterns/patterns_test.go index 9af115e..99ca7fd 100644 --- a/e2e/patterns/patterns_test.go +++ b/e2e/patterns/patterns_test.go @@ -3395,8 +3395,8 @@ func TestMultiUserSync(t *testing.T) { // chromedp.NewContext(parent) where parent is a chromedp context creates // a NEW TAB in the same browser. Cookies and storage are shared, so both - // tabs land in the same session group — the prerequisite for Sync() - // auto-dispatch (mount.go:1466-1468) to fire across them. + // tabs land in the same session group — the prerequisite for + // BroadcastAction to dispatch to peers. peerCtx, peerCancel := chromedp.NewContext(ctx) defer peerCancel() if err := chromedp.Run(peerCtx, @@ -3414,13 +3414,12 @@ func TestMultiUserSync(t *testing.T) { ); err != nil { t.Fatalf("Tab 1 did not reflect Counter: 1: %v", err) } - // Peer must see the same value via Sync auto-dispatch — Increment - // did NOT call BroadcastAction; Sync fires unconditionally because - // HasSync && !syncExplicitlyBroadcast at mount.go:1466. + // Peer must see the same value via the explicit RefreshCounter + // broadcast issued by Increment. if err := chromedp.Run(peerCtx, e2etest.WaitForText(`article`, "Counter: 1", 3*time.Second), ); err != nil { - t.Fatalf("Peer did not pick up Counter: 1 from Sync auto-dispatch: %v", err) + t.Fatalf("Peer did not pick up Counter: 1 from BroadcastAction: %v", err) } })