From 9ba2df0851054aa3d5962d25c8075892d898caa1 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Thu, 21 May 2026 17:08:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(recipes)!:=20Phase=206=20=E2=80=94=20migra?= =?UTF-8?q?te=20recipes=20+=20=5Fapp=20code=20to=20Subscribe/Publish=20(li?= =?UTF-8?q?vetemplate=20v0.10.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of the broadcast-redesign wave (livetemplate#415) — docs side. go.mod + e2e/go.mod bumped to livetemplate v0.10.0 (separate Go modules; both must bump in lockstep). 8 user-named recipes rewritten at section-level depth (conceptual content, not search-replace): - content/index.md — top-of-funnel "Try it" paragraph. - content/recipes/counter/index.md — full "How Subscribe + Publish route" section replaces the prior BroadcastAction routing section; intro, frontmatter, and "What next" list updated. - content/recipes/shared-notepad/index.md — frontmatter, intro bullets, Mount + Save sections (two-step Subscribe+Publish shape with the "no Subscribe, no fan-out" caveat), Refresh section, scaling table. - content/recipes/todos/index.md — same-user-multiple-tabs bullet, scaling table cell, "What next" Counter link. - content/recipes/broadcasting.md — full reframe around the two-step shape; Mount section, "Sending — Publish under the lock-release rule", multi-replica section now describes the topic-channel + seen-ring. - content/recipes/sync-and-broadcast.md — full rewrite. Title: "Broadcast & Server Push" → "Sync & Server Push". New 4-row When-to-pick matrix. Mermaid diagram rewired with the Subscribe-aware sequence. - content/recipes/architecture-flow.md — two-tab paragraph reframed. - content/recipes/progressive-enhancement/index.md — Tier B limitation bullet reframed to Publish-via-WS. Inline _app/*.go migration (forced by the go.mod bump): - content/recipes/counter/_app/counter.go: Mount adds Subscribe(SelfTopic); Increment/Decrement swap to Publish. - content/recipes/shared-notepad/_app/controller.go: Mount adds Subscribe; Save publishes; Refresh comment rewritten. - content/recipes/shared-notepad/_app/handler.go: 3 comment blocks reworded. - content/recipes/shared-notepad/_app/notepad.tmpl: "How it works" bullet. - content/recipes/todos/_app/controller.go: Mount adds Subscribe; 4 call sites swap to Publish. - content/recipes/patterns/_app/handlers_realtime.go: 3 controllers (MultiUserSync, Broadcasting, Presence) gain Mount Subscribe; 5 Publish call sites; 2 comment blocks reworded. - content/recipes/patterns/_app/handlers_lists.go: 1 comment. - content/recipes/patterns/_app/data.go: 2 pattern descriptions. - content/recipes/patterns/_app/templates/realtime/{broadcasting, multi-user-sync}.tmpl: explanatory paragraphs. - cmd/site/main.go: shared-notepad mount-rationale comment block. V20-zero cleanup (beyond user-named recipes): - content/recipes/apps/index.md, content/getting-started/your-first-app.md: flagged by the V20 substring sweep; reframed. - e2e/shared-notepad/notepad_test.go, e2e/patterns/patterns_test.go: comments and assertion messages. V15 acceptance sweep GREEN — 5 chromedp e2e tests / 15 subtests pass end-to-end against the migrated wiring (TestMultiUserSync, TestBroadcasting, TestPresence, TestSharedNotepad_E2E, TestSharedNotepad_MultiUserIsolation). The Late_Joiner_Sees_Current_Counter_On_Mount subtest specifically validates the Mount-side Subscribe + snapshot pattern works when a tab opens after others have already published. Co-Authored-By: Claude Opus 4.7 (1M context) --- content/getting-started/your-first-app.md | 8 ++-- content/index.md | 2 +- content/recipes/apps/index.md | 2 +- content/recipes/architecture-flow.md | 2 +- content/recipes/broadcasting.md | 31 ++++++------- content/recipes/counter/index.md | 33 +++++++------- .../_app/templates/realtime/broadcasting.tmpl | 2 +- .../templates/realtime/multi-user-sync.tmpl | 2 +- .../recipes/progressive-enhancement/index.md | 2 +- .../recipes/shared-notepad/_app/notepad.tmpl | 2 +- content/recipes/shared-notepad/index.md | 37 +++++++++------- content/recipes/sync-and-broadcast.md | 44 ++++++++++++------- content/recipes/todos/index.md | 6 +-- 13 files changed, 96 insertions(+), 77 deletions(-) diff --git a/content/getting-started/your-first-app.md b/content/getting-started/your-first-app.md index 43b2487..25b3991 100644 --- a/content/getting-started/your-first-app.md +++ b/content/getting-started/your-first-app.md @@ -35,7 +35,7 @@ Then a controller and two action methods: ```go include="../recipes/counter/_app/counter.go" lines="13-33" ``` -Action methods are exported on the controller, and their names ARE the action names — `Increment` and `Decrement` are what the template will reference. The `BroadcastAction` calls are how multi-tab sync works (Step 6). +Action methods are exported on the controller, and their names ARE the action names — `Increment` and `Decrement` are what the template will reference. The `Mount` + `Publish` calls are how multi-tab sync works (Step 6). Now wire it up in `main.go`: @@ -111,7 +111,7 @@ Look at the handlers from Step 2 — note the highlighted lines: ```go include="../recipes/counter/_app/counter.go" lines="22-33" highlight="24,31" ``` -`ctx.BroadcastAction("Increment", nil)` (and the matching `Decrement`) tells LiveTemplate to apply the same action on every other connection in the same session group — multiple tabs and embeds within your browser. Without it, each tab has its own count; with it, they stay in lockstep. +Two things make multi-tab sync work. In `Mount`, `ctx.Subscribe(ctx.SelfTopic())` opts the connection in to peer fan-out for its own session (`SelfTopic()` resolves to the reserved-namespace string `lvt:session:` and is ACL-exempt). Then in each action, `ctx.Publish(ctx.SelfTopic(), "Increment", nil)` (and the matching `Decrement`) fans the named action out to every other connection that subscribed. Without the Subscribe, the Publish has no receiver; without the Publish, no peer ever runs the action. With both, the tabs stay in lockstep. To prove it, here are two embeds against the same counter, side by side: @@ -125,7 +125,7 @@ To prove it, here are two embeds against the same counter, side by side: -Click `+1` in one — watch the other update in real time. They're talking to the same upstream session, and `BroadcastAction` is what makes them stay synced. (On a narrow viewport the embeds stack vertically — the broadcast still works.) +Click `+1` in one — watch the other update in real time. They're talking to the same upstream session, and the Mount-side `Subscribe(SelfTopic())` plus action-side `Publish(SelfTopic(), ...)` are what makes them stay synced. (On a narrow viewport the embeds stack vertically — the fan-out still works.) > **Why does this stay scoped to your browser?** LiveTemplate's default authenticator (`AnonymousAuthenticator`) uses a cookie to assign each browser a stable session group. Tabs from the same browser share that group — that's why the two embeds above sync. Different browsers — or an incognito window in the same browser — get different cookies, different groups, and isolated state. For a public docs site this is the right default: every visitor gets a clean slate, and the broadcast demo still proves the feature within their own browser. See [Recipes/Counter, deeper](/recipes/counter) for the full session-group + scaling story. @@ -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. -- [Broadcast & Server Push](/recipes/sync-and-broadcast) — when to use `BroadcastAction()` vs `TriggerAction()`, and how sessions are scoped. +- [Sync & Server Push](/recipes/sync-and-broadcast) — when to use `Subscribe`/`Publish` peer fan-out vs `TriggerAction()`, and how sessions are scoped. - [App recipes](/recipes/apps/) — runnable apps including chat, todos, file uploads, auth. diff --git a/content/index.md b/content/index.md index 6dc61db..3774b0a 100644 --- a/content/index.md +++ b/content/index.md @@ -16,7 +16,7 @@ LiveTemplate is a Go library for building reactive web UIs from standard `html/t ```embed-lvt path="/apps/counter/" upstream="http://localhost:9091" height="320px" ``` -Click the buttons. Each click POSTs the action to the Go server; the server runs `Increment`, re-renders the template, diffs against the previous render, and sends only the changed text node back. The form, the buttons, and the count display are never re-created — only the count's text changes. Open this page in a second tab on the same machine: clicks in one tab show up in the other in real time, because every handler ends with `ctx.BroadcastAction(...)`. +Click the buttons. Each click POSTs the action to the Go server; the server runs `Increment`, re-renders the template, diffs against the previous render, and sends only the changed text node back. The form, the buttons, and the count display are never re-created — only the count's text changes. Open this page in a second tab on the same machine: clicks in one tab show up in the other in real time, because each tab opts in to peer fan-out via `ctx.Subscribe(ctx.SelfTopic())` in `Mount` and every handler ends with `ctx.Publish(ctx.SelfTopic(), ...)`. The widget above is a real, deployed LiveTemplate app — the same code as the [Your First App](/getting-started/your-first-app) tutorial, embedded inline through tinkerdown's auto-proxy. diff --git a/content/recipes/apps/index.md b/content/recipes/apps/index.md index 940b260..7e72a84 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 explicit `BroadcastAction` +- **Real-time sync** — open two tabs as the same user; changes appear instantly via an opt-in `ctx.Subscribe(ctx.SelfTopic())` in Mount and an explicit `ctx.Publish(ctx.SelfTopic(), ...)` from each mutating action - **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 40054a1..c0300f8 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.BroadcastAction("RefreshTodos", nil)` after each shared mutation. +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 each tab opted into peer fan-out via `ctx.Subscribe(ctx.SelfTopic())` in `Mount`, and the controller calls `ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil)` after each shared mutation. ## How this page works diff --git a/content/recipes/broadcasting.md b/content/recipes/broadcasting.md index bfb9d0e..2b1f0c3 100644 --- a/content/recipes/broadcasting.md +++ b/content/recipes/broadcasting.md @@ -1,22 +1,22 @@ --- title: "Broadcasting, deeper" -description: "How ctx.BroadcastAction routes within a session group, why some state belongs on the controller and not in lvt:\"persist\", and the two mutex rules that keep it from deadlocking." +description: "How Subscribe(SelfTopic())+Publish routes within a session group, why some state belongs on the controller and not in lvt:\"persist\", and the two mutex rules that keep it from deadlocking." source_repo: https://github.com/livetemplate/docs source_path: content/recipes/broadcasting.md --- # Broadcasting, deeper -[Counter, deeper](/recipes/counter) showed `ctx.BroadcastAction("Increment", nil)` keeping every tab in a single browser in sync — a counter clicked in one tab ticks in the others. The scope of "every tab" was the [session group](/reference/session): the browser's cookie pins all its tabs to one group, and `BroadcastAction` fans the named action out to every connection in that group. +[Counter, deeper](/recipes/counter) showed `ctx.Subscribe(ctx.SelfTopic())` in `Mount` plus `ctx.Publish(ctx.SelfTopic(), "Increment", nil)` keeping every tab in a single browser in sync — a counter clicked in one tab ticks in the others. The scope of "every tab" was the [session group](/reference/session): the browser's cookie pins all its tabs to one group, `SelfTopic()` resolves to `lvt:session:` for that group, and `Publish` fans the named action out to every connection that subscribed. -Broadcasting goes further within the same scope. Counter shared one integer; this pattern shares a multi-author message log. Same `BroadcastAction` primitive, two design choices that change everything — which fields are per-connection vs persisted, and where the source of truth lives. +Broadcasting goes further within the same scope. Counter shared one integer; this pattern shares a multi-author message log. Same Subscribe/Publish primitives, two design choices that change everything — which fields are per-connection vs persisted, and where the source of truth lives. ```embed-lvt path="/recipes/ui-patterns/realtime/broadcasting" upstream="http://localhost:9091" height="380px" ``` -Open the page in a second tab. Join with a different name. Send a message from either side. Both update. Both tabs are in the same session group (same cookie), so the broadcast reaches both — but each tab keeps its own `Username` because identity is per-connection, not persisted. +Open the page in a second tab. Join with a different name. Send a message from either side. Both update. Both tabs are in the same session group (same cookie), so each tab's `SelfTopic()` resolves to the same string, and a Publish from either reaches both — but each tab keeps its own `Username` because identity is per-connection, not persisted. -(For a setup where every visitor — across browsers, across machines — sees the same broadcasts, you'd swap [`AnonymousAuthenticator`](/reference/authentication) for one that returns a constant group ID. That's an authentication choice, not a `BroadcastAction` choice.) +(For a setup where every visitor — across browsers, across machines — sees the same fan-out, you'd swap [`AnonymousAuthenticator`](/reference/authentication) for one that returns a constant group ID, or define a developer-named topic like `"announcements"` and admit it in `WithTopicACL`. That's an authentication or ACL choice, not a `Publish` choice.) ## Anatomy of the state @@ -25,7 +25,7 @@ Open the page in a second tab. Join with a different name. Send a message from e Note what's *not* persisted. `Username` looks like a candidate for `lvt:"persist"` — it's user identity, surely you want it to survive a reconnect? But persist storage is keyed by **session group**, so persisting `Username` would force every tab in the same browser to share one identity, defeating the demo where two tabs join as different users. -The pattern that *does* persist state across reconnects is `ReconnectionState` (also in this file) — different recipe, same package. Same broadcast scope (session group), but every connection sees the same value across drops because the field is `lvt:"persist"`-tagged. +The pattern that *does* persist state across reconnects is `ReconnectionState` (also in this file) — different recipe, same package. Same fan-out scope (session group), but every connection sees the same value across drops because the field is `lvt:"persist"`-tagged. ## Where the messages live @@ -34,36 +34,37 @@ The pattern that *does* persist state across reconnects is `ReconnectionState` ( The message log is on the **controller**, not in state. State is per-connection; the controller is the singleton dependency layer the [Controller+State pattern](/reference/controller-pattern) puts in front of every connection routed to this handler. `c.messages` is the source of truth — every tab reads from it under the same `RWMutex`. -The `Mount` method runs on every initial render — without it, a tab that opens *after* others have sent messages would render with `Messages: nil` until the next broadcast arrives. Mount snapshots the current log into per-connection state so each tab starts coherent. +The `Mount` method runs on every initial render — and in v0.10.0 it does **two** things: opt the connection into peer fan-out via `ctx.Subscribe(ctx.SelfTopic())`, *and* snapshot the current log into per-connection state. Without the Subscribe, a Publish from another tab would have no receiver in this session and the demo wouldn't work. Without the snapshot, a tab that opens *after* others have sent messages would render with `Messages: nil` until the next Publish arrives. -## The broadcast +## Sending — Publish under the lock-release rule ```go include="./patterns/_app/handlers_realtime.go" region="broadcasting-send" ``` Two non-obvious mutex rules in this method: -1. **`BroadcastAction` after the lock release.** Holding the connection registry mutex while queuing broadcasts can deadlock with peer dispatches taking the same mutex from the other side. The pattern: mutate-and-snapshot under your lock, release, *then* broadcast. +1. **`Publish` after the lock release.** Holding the connection registry mutex while queuing publishes can deadlock with peer dispatches taking the same mutex from the other side. The pattern: mutate-and-snapshot under your lock, release, *then* Publish. 2. **`snapshotLocked()` requires the caller hold the lock.** A naked `slices.Clone(c.messages)` reads concurrently with `Send`'s append and races. The `Locked` suffix is documentation: violate it and you get a data race the test suite will catch under `-race`. -The third rule is implicit — `c.messages` is uncapped here. Production apps would ring-buffer, paginate, or persist to a TTL store. This demo skips that to keep the focus on `BroadcastAction` itself. +The third rule is implicit — `c.messages` is uncapped here. Production apps would ring-buffer, paginate, or persist to a TTL store. This demo skips that to keep the focus on the fan-out machinery itself. ## What peers do ```go include="./patterns/_app/handlers_realtime.go" region="broadcasting-newmessage" ``` -`NewMessage` runs on every peer when the broadcast fires. It reads the shared log under `RLock` and copies into per-connection state. The template re-renders; the diff goes over the wire as patches, not full HTML. +`NewMessage` runs on every peer connection that subscribed to `SelfTopic()` when the Publish fires. It reads the shared log under `RLock` and copies into per-connection state. The template re-renders; the diff goes over the wire as patches, not full HTML. -This is why broadcast volume isn't proportional to message size: each peer's wire bytes equal the diff between its local state before and after `NewMessage`, which is roughly "one new message appended to the messages list." +This is why fan-out volume isn't proportional to message size: each peer's wire bytes equal the diff between its local state before and after `NewMessage`, which is roughly "one new message appended to the messages list." ## When this scales -Single process, single replica: works as-shown. The mutex serializes appends; the broadcast is in-process Pub/Sub. +Single process, single replica: works as-shown. The mutex serializes appends; the fan-out is in-process pub/sub. -Multi-replica: swap in-process broadcast for Redis Pub/Sub via [`WithPubSubBroadcaster`](/reference/pubsub). The handler shape stays identical — the `Send` and `NewMessage` methods don't change. What changes is *where* `c.messages` lives (a shared store instead of a Go slice) and *how* `BroadcastAction` propagates (Redis publish, replica subscribers fire `NewMessage` on their connections). +Multi-replica: swap in-process fan-out for Redis Pub/Sub via [`WithPubSubBroadcaster`](/reference/pubsub). The handler shape stays identical — the `Mount`, `Send`, and `NewMessage` methods don't change. What changes is *where* `c.messages` lives (a shared store instead of a Go slice) and *how* the Publish propagates (Redis publish to `livetemplate:topic_action:`, replica subscribers fire `NewMessage` on their own subscribed connections; the framework's seen-ring deduplicates the SUBSCRIBE+PSUBSCRIBE double-fire for cross-instance wildcard topics). ## What's next -The reconnection-recovery pattern (live demo at [/recipes/ui-patterns/realtime/reconnection](/recipes/ui-patterns/realtime/reconnection)) is the persist-state companion. Same `BroadcastAction` shape, but the demo state survives a WebSocket drop because the fields are `lvt:"persist"`-tagged. A future recipe will go deep on it; for now the live widget plus its source in the same `_app/` is the reference. +The reconnection-recovery pattern (live demo at [/recipes/ui-patterns/realtime/reconnection](/recipes/ui-patterns/realtime/reconnection)) is the persist-state companion. Same Subscribe/Publish shape, but the demo state survives a WebSocket drop because the fields are `lvt:"persist"`-tagged. A future recipe will go deep on it; for now the live widget plus its source in the same `_app/` is the reference. + diff --git a/content/recipes/counter/index.md b/content/recipes/counter/index.md index 2574f9d..17b58fa 100644 --- a/content/recipes/counter/index.md +++ b/content/recipes/counter/index.md @@ -1,15 +1,15 @@ --- title: "Counter, deeper" -description: "Past the +1 button: how BroadcastAction routes between sessions, why AnonymousAuthenticator is the right default for public demos, and where this pattern stops scaling." +description: "Past the +1 button: how Subscribe + Publish route between session connections, why AnonymousAuthenticator is the right default for public demos, and where this pattern stops scaling." source_repo: https://github.com/livetemplate/docs source_path: content/recipes/counter/index.md --- # Counter, deeper -Most "counter" demos stop at "click +1, see number tick." Useful for proving the framework works; not so useful when you actually have to ship one. This recipe goes past the demo into the production-shaped questions: how `BroadcastAction` routes between sessions, why the cookie-bound session group matters for "multi-tab sync without leaking to other users," and what breaks first when this pattern meets real load. +Most "counter" demos stop at "click +1, see number tick." Useful for proving the framework works; not so useful when you actually have to ship one. This recipe goes past the demo into the production-shaped questions: how `Subscribe` + `Publish` route between the connections of a single session, why the cookie-bound session group matters for "multi-tab sync without leaking to other users," and what breaks first when this pattern meets real load. -The code is the same counter from [Your First App](/getting-started/your-first-app) — but the framing is different. Where that walkthrough builds the counter from scratch, this one stares at the four lines that do the actual work and unpacks them. +The code is the same counter from [Your First App](/getting-started/your-first-app) — but the framing is different. Where that walkthrough builds the counter from scratch, this one stares at the five lines that do the actual work and unpacks them. ```embed-lvt path="/apps/counter/" upstream="http://localhost:9091" height="180px" ``` @@ -30,11 +30,11 @@ There's not much to it. The choices that matter for production are the two `live ## Why `AnonymousAuthenticator` is the production default -LiveTemplate's `Authenticator` interface answers a single question on every HTTP and WebSocket request: *"who is this client, and which session group do they belong to?"* The session group is what `BroadcastAction` routes between. Two requests with the same group ID share state; different group IDs don't. +LiveTemplate's `Authenticator` interface answers a single question on every HTTP and WebSocket request: *"who is this client, and which session group do they belong to?"* The session group is what `SelfTopic()` resolves to — `Subscribe(SelfTopic())` opts a connection into that group's topic, and `Publish(SelfTopic(), ...)` fans out to every subscribed connection in it. Two requests with the same group ID share the same topic string; different group IDs get different strings. `AnonymousAuthenticator` (the framework's default, what this recipe uses) issues a cookie-bound group ID on first contact: -- Same browser, multiple tabs → same cookie → same group → broadcast works +- Same browser, multiple tabs → same cookie → same group → same `SelfTopic()` → peer fan-out works - Different browser → different cookie → different group → isolated state - Incognito window → its own cookie → its own group → clean slate @@ -50,21 +50,24 @@ livetemplate.WithAuthenticator(sharedAuth{}) livetemplate.WithAuthenticator(&livetemplate.AnonymousAuthenticator{}) ``` -The `BroadcastAction` calls didn't change. The state struct didn't change. Only the routing rule for "who counts as the same session" changed, and that one swap converted a demo into a production-shaped widget. +The `Subscribe` + `Publish` calls didn't change. The state struct didn't change. Only the routing rule for "who counts as the same session" changed, and that one swap converted a demo into a production-shaped widget. -## How `BroadcastAction` routes +## How `Subscribe` + `Publish` route -The two action methods do the obvious thing — bump the counter, return the new state — and then call `ctx.BroadcastAction`: +The work happens in two places: `Mount` opts each connection in via `ctx.Subscribe(ctx.SelfTopic())`, and the action methods bump the counter and fan out via `ctx.Publish(ctx.SelfTopic(), ...)`: -```go include="./_app/counter.go" lines="22-33" highlight="24,31" +```go include="./_app/counter.go" lines="18-43" highlight="20,30,37" ``` -`BroadcastAction("Increment", nil)` adds an action to the broadcast queue. It does **not** apply the action immediately to other connections; it queues it. After the current request's response is sent, the framework drains the queue: for every other connection in the same session group, run `Increment` against that connection's local state. +The two-step shape matters. Peer fan-out is **opt-in** — a connection that never called `Subscribe` would not receive the published action even if every other tab in the same group did. `SelfTopic()` resolves to a reserved-namespace topic string (`lvt:session:`) that's ACL-exempt, so `Subscribe(SelfTopic())` always succeeds and matches whatever other Mount-time `Subscribe(SelfTopic())` calls produced in this session. -Two consequences worth knowing: +`Publish("Increment", nil)` adds an action to the per-action publish queue. It does **not** apply the action immediately to other connections; it queues it. After the current request's response is sent, the framework drains the queue: for every other connection that subscribed to this topic, run `Increment` against that connection's local state. -- **Each connection still has its own state copy.** Broadcast doesn't share state — it replays actions. A connection that's been disconnected for a while doesn't get a magical state update; it gets the actions it missed when it reconnects, applied in order. -- **The broadcast is fire-and-forget within a request.** The current request's caller doesn't wait for the broadcast to finish. If you broadcast and then return, the response goes to the originating client immediately; the other clients see the update milliseconds later as the queue drains. +Three consequences worth knowing: + +- **Each connection still has its own state copy.** Publish doesn't share state — it replays actions. A connection that's been disconnected for a while doesn't get a magical state update; it gets the actions it missed when it reconnects, applied in order against its own local state. +- **Fan-out is fire-and-forget within a request.** The current request's caller doesn't wait for the publish to finish. If you publish and then return, the response goes to the originating client immediately; the other clients see the update milliseconds later as the queue drains. +- **No `Subscribe`, no fan-out.** If you forget the `Mount`-side `Subscribe(SelfTopic())`, `Publish` runs without errors but reaches zero peer connections. The "my peer tabs stopped updating" troubleshooting question is almost always "did the receiver subscribe?" To prove the routing, here are two embeds against the same recipe app, side by side: @@ -118,6 +121,6 @@ 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. -- [Broadcast & Server Push](/recipes/sync-and-broadcast) — when to use explicit broadcast and server push. +- [Reference — Server Actions](/reference/server-actions) — the action lifecycle, including `Publish` ordering rules and gotchas. +- [Sync & Server Push](/recipes/sync-and-broadcast) — when to use Subscribe/Publish peer fan-out vs server-initiated TriggerAction. - [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/templates/realtime/broadcasting.tmpl b/content/recipes/patterns/_app/templates/realtime/broadcasting.tmpl index eaec0d9..17692ba 100644 --- a/content/recipes/patterns/_app/templates/realtime/broadcasting.tmpl +++ b/content/recipes/patterns/_app/templates/realtime/broadcasting.tmpl @@ -1,7 +1,7 @@ {{define "content"}}

Broadcasting

-

ctx.BroadcastAction("NewMessage", nil) fans the named action out to every other connection in the session group. Peers receive it as a regular action invocation; their handler reads the shared message log under a mutex and refreshes local state. The broadcast is queued during the action and executes after it returns successfully.

+

Mount calls ctx.Subscribe(ctx.SelfTopic()) to opt this connection in to peer fan-out; Send then calls ctx.Publish(ctx.SelfTopic(), "NewMessage", nil) to fan the named action out to every other connection that subscribed. Peers receive it as a regular action invocation; their handler reads the shared message log under a mutex and refreshes local state. The publish is queued during the action and executes after it returns successfully.

{{if eq .Username ""}} 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 5c72ff2..f6ac520 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 Refresh

-

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

+

Mount opts each connection in via ctx.Subscribe(ctx.SelfTopic()); Increment updates the shared counter, then explicitly calls ctx.Publish(ctx.SelfTopic(), "RefreshCounter", nil) so peer connections that subscribed reload from the controller's mutex-protected state.

Counter: {{.Counter}}

diff --git a/content/recipes/progressive-enhancement/index.md b/content/recipes/progressive-enhancement/index.md index a147556..a29b42e 100644 --- a/content/recipes/progressive-enhancement/index.md +++ b/content/recipes/progressive-enhancement/index.md @@ -110,7 +110,7 @@ Three tiers from one controller is a lot of mileage from one option flag, but th - **Tier C requires `` for every action.** Pure-button no-form interactions (e.g., `