From da6d846ffe5bb6a802be8739cb4d0cc69ab0d442 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Wed, 20 May 2026 19:56:18 +0000 Subject: [PATCH 1/2] feat(broadcast): migrate to Subscribe(SelfTopic())+Publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the broadcast-action-redesign proposal removes ctx.BroadcastAction from livetemplate in v0.10.0. This PR migrates the examples monorepo to the new opt-in peer-fanout pattern: - Mount: ctx.Subscribe(ctx.SelfTopic()) - Actions: ctx.Publish(ctx.SelfTopic(), "ActionName", data) Bumps livetemplate pin v0.9.0 → v0.9.2 (where Subscribe / Publish / SelfTopic shipped via Phase 1). Migrated 4 apps (11 BroadcastAction call sites): - chat/main.go (UserJoined / NewMessage / UserLeft) - shared-notepad/main.go (Refresh) - landing-demo/main.go (Increment / Decrement / Reset; new Mount) - todos/controller.go (RefreshTodos × 4) Also reworded the BroadcastAction subtest in landing-demo/landing_demo_test.go (t.Run name "BroadcastAction_Propagates_To_Peer_Tab" → "Publish_Propagates_To_Peer_Tab"; skip message updated). Test remains skipped — the chromedp peer-tab issue tracked at livetemplate/examples#94 is independent of the broadcast→publish rename. Substring grep across the repo for "BroadcastAction" returns zero matches. Lands BEFORE livetemplate v0.10.0 — the migrated code uses Subscribe / Publish / SelfTopic which already ship in v0.9.2, so this PR builds and tests cleanly against the current published library. Once livetemplate v0.10.0 ships, re-pin and the apps continue to work without further changes. Refs livetemplate#415 (broadcast-action-redesign proposal) Refs livetemplate#429 (Phase 5 livetemplate-side PR) --- chat/main.go | 18 ++++++++++++++---- go.mod | 2 +- go.sum | 4 ++-- landing-demo/landing_demo_test.go | 14 +++++++------- landing-demo/main.go | 25 ++++++++++++++++++++----- shared-notepad/main.go | 9 ++++++++- todos/controller.go | 21 +++++++++++++++++---- 7 files changed, 69 insertions(+), 24 deletions(-) diff --git a/chat/main.go b/chat/main.go index d2d7125..d28c121 100644 --- a/chat/main.go +++ b/chat/main.go @@ -37,8 +37,12 @@ type Message struct { Timestamp string `json:"timestamp"` } -// Mount is called once per session group. Sets up initial empty state. +// Mount runs once per session group. Subscribes the self-topic so peer tabs +// receive the UserJoined / NewMessage / UserLeft dispatches Publish'd below. func (c *ChatController) Mount(state ChatState, ctx *livetemplate.Context) (ChatState, error) { + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return state, err + } c.mu.RLock() defer c.mu.RUnlock() state.Messages = c.copyMessages() @@ -74,7 +78,9 @@ func (c *ChatController) Join(state ChatState, ctx *livetemplate.Context) (ChatS c.mu.Unlock() // Tell other tabs someone joined - ctx.BroadcastAction("UserJoined", nil) + if err := ctx.Publish(ctx.SelfTopic(), "UserJoined", nil); err != nil { + return state, err + } return state, nil } @@ -109,7 +115,9 @@ func (c *ChatController) Send(state ChatState, ctx *livetemplate.Context) (ChatS c.mu.Unlock() // Tell other tabs about the new message - ctx.BroadcastAction("NewMessage", nil) + if err := ctx.Publish(ctx.SelfTopic(), "NewMessage", nil); err != nil { + return state, err + } return state, nil } @@ -135,7 +143,9 @@ func (c *ChatController) Leave(state ChatState, ctx *livetemplate.Context) (Chat state.OnlineCount = c.countOnline() c.mu.Unlock() - ctx.BroadcastAction("UserLeft", nil) + if err := ctx.Publish(ctx.SelfTopic(), "UserLeft", nil); err != nil { + return state, err + } return state, nil } diff --git a/go.mod b/go.mod index 94cde80..1e8bf2e 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/chromedp/chromedp v0.14.2 github.com/go-playground/validator/v10 v10.30.1 github.com/gorilla/websocket v1.5.3 - github.com/livetemplate/livetemplate v0.9.0 + github.com/livetemplate/livetemplate v0.9.2 github.com/livetemplate/lvt v0.1.6 github.com/livetemplate/lvt/components v0.1.2 modernc.org/sqlite v1.43.0 diff --git a/go.sum b/go.sum index 1724d37..1f47842 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/livetemplate/livetemplate v0.9.0 h1:6vHNNpZLIY4gO08/Gth3vr1F3A3ANwSKTQvfuy1I8js= -github.com/livetemplate/livetemplate v0.9.0/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= +github.com/livetemplate/livetemplate v0.9.2 h1:zF/mbhXxp5uLX3UBdgkdBk+Jmhfx3g5DAlUCisTXoZM= +github.com/livetemplate/livetemplate v0.9.2/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= github.com/livetemplate/lvt v0.1.6 h1:1rDU5hDo+EtZ0mT+868wYD9czF2EHEgdacS4kpIUPQ4= github.com/livetemplate/lvt v0.1.6/go.mod h1:OrTdx3zvh0WeuugVueQoRG3ILRNJe/dThErxKsos6Rw= github.com/livetemplate/lvt/components v0.1.2 h1:MM2M5IZnsUAu0py9ZbtcQCo0bvUrL4Z3Ly/yDkYNyag= diff --git a/landing-demo/landing_demo_test.go b/landing-demo/landing_demo_test.go index f675c6e..00ffb6b 100644 --- a/landing-demo/landing_demo_test.go +++ b/landing-demo/landing_demo_test.go @@ -213,19 +213,19 @@ func TestLandingDemoE2E(t *testing.T) { } }) - t.Run("BroadcastAction_Propagates_To_Peer_Tab", func(t *testing.T) { + t.Run("Publish_Propagates_To_Peer_Tab", func(t *testing.T) { // SKIP rationale: this scenario IS what the README and landing // page describe — "open this page in another tab and watch them - // sync." The controller calls BroadcastAction after counter - // mutations to dispatch peer-tab updates. But - // reliably exercising that across two chromedp browser contexts - // turns out to need more work than fits this PR: + // sync." The controller Publishes to SelfTopic() after counter + // mutations to dispatch peer-tab updates. But reliably exercising + // that across two chromedp browser contexts turns out to need more + // work than fits this PR: // - chromedp.NewContext(parent) shares the browser allocator // but each new target gets its own request context, so the // session cookie may not propagate the way two real-world // tabs in the same browser would. // - Even when both tabs DO land in the same session group, - // the peer-side broadcast round-trip wasn't observed within 10s + // the peer-side publish round-trip wasn't observed within 10s // in this harness, suggesting either a session-group // mismatch or an artifact of how the docker-chrome target // attaches. @@ -233,7 +233,7 @@ func TestLandingDemoE2E(t *testing.T) { // two tabs of the same browser and click +1 in either; both // counters move. The cross-tab claim on the landing page holds. // Tracking proper e2e coverage as a follow-up. - t.Skip("cross-tab BroadcastAction e2e: tracked at livetemplate/examples#94 — pending session-group propagation work in chromedp peer context; manual verification documented in test comment") + t.Skip("cross-tab Publish e2e: tracked at livetemplate/examples#94 — pending session-group propagation work in chromedp peer context; manual verification documented in test comment") }) t.Run("HTTP_POST_Fallback_Without_JS", func(t *testing.T) { diff --git a/landing-demo/main.go b/landing-demo/main.go index 5b7aa47..2c44ec0 100644 --- a/landing-demo/main.go +++ b/landing-demo/main.go @@ -1,7 +1,7 @@ // Minimal LiveTemplate counter, sized for a landing-page demo. The whole // app fits in this file; the template is a single counter.tmpl. Per-session -// state means each visitor has their own counter; explicit BroadcastAction -// calls keep their WebSocket-connected tabs in step. +// state means each visitor has their own counter; explicit Publish-to- +// SelfTopic calls keep their WebSocket-connected tabs in step. package main import ( @@ -19,9 +19,20 @@ type CounterState struct { Count int `json:"count" lvt:"persist"` } +// Mount subscribes the self-topic so peer tabs receive the Increment / +// Decrement / Reset dispatches Publish'd from the actions below. +func (c *CounterController) Mount(s CounterState, ctx *livetemplate.Context) (CounterState, error) { + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return s, err + } + return s, nil +} + func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) { s.Count++ - ctx.BroadcastAction("Increment", nil) + if err := ctx.Publish(ctx.SelfTopic(), "Increment", nil); err != nil { + return s, err + } return s, nil } @@ -31,13 +42,17 @@ func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) if s.Count > 0 { s.Count-- } - ctx.BroadcastAction("Decrement", nil) + if err := ctx.Publish(ctx.SelfTopic(), "Decrement", nil); err != nil { + return s, err + } return s, nil } func (c *CounterController) Reset(s CounterState, ctx *livetemplate.Context) (CounterState, error) { s.Count = 0 - ctx.BroadcastAction("Reset", nil) + if err := ctx.Publish(ctx.SelfTopic(), "Reset", nil); err != nil { + return s, err + } return s, nil } diff --git a/shared-notepad/main.go b/shared-notepad/main.go index ff9959e..f1112d6 100644 --- a/shared-notepad/main.go +++ b/shared-notepad/main.go @@ -25,6 +25,11 @@ type NotepadState struct { } func (c *NotepadController) Mount(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) { + // Subscribe the self-topic so peer tabs of the same user receive the + // Refresh dispatch from Save's Publish below — multi-device sync. + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return state, err + } state.Username = ctx.UserID() c.mu.RLock() if saved, ok := c.notes[ctx.UserID()]; ok { @@ -45,7 +50,9 @@ func (c *NotepadController) Save(state NotepadState, ctx *livetemplate.Context) c.notes[ctx.UserID()] = state c.mu.Unlock() - ctx.BroadcastAction("Refresh", nil) + if err := ctx.Publish(ctx.SelfTopic(), "Refresh", nil); err != nil { + return state, err + } return state, nil } diff --git a/todos/controller.go b/todos/controller.go index b9ef38d..becf5ed 100644 --- a/todos/controller.go +++ b/todos/controller.go @@ -17,6 +17,11 @@ type TodoController struct { } func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + // Subscribe self-topic so peer tabs of the same user receive the + // RefreshTodos dispatch from Publish calls in actions below. + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return state, err + } state.Username = ctx.UserID() state = initComponents(state) return c.loadTodos(context.Background(), state, ctx.UserID()) @@ -60,7 +65,9 @@ func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoSt if err != nil { return state, err } - ctx.BroadcastAction("RefreshTodos", nil) + if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil { + return state, err + } return state, nil } @@ -99,7 +106,9 @@ func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (Tod if err != nil { return state, err } - ctx.BroadcastAction("RefreshTodos", nil) + if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil { + return state, err + } return state, nil } @@ -134,7 +143,9 @@ func (c *TodoController) ConfirmDeleteConfirm(state TodoState, ctx *livetemplate if err != nil { return state, err } - ctx.BroadcastAction("RefreshTodos", nil) + if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil { + return state, err + } return state, nil } @@ -225,7 +236,9 @@ func (c *TodoController) ClearCompleted(state TodoState, ctx *livetemplate.Conte if err != nil { return state, err } - ctx.BroadcastAction("RefreshTodos", nil) + if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil { + return state, err + } return state, nil } From f73d1a471d8ebd21343b14152a46093c6096dc51 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Wed, 20 May 2026 20:25:31 +0000 Subject: [PATCH 2/2] docs: explain why Publish errors are propagated (bot R1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the claude-review concern about partial-mutation semantics when Publish returns an error after the primary mutation has already committed. The behavior is intentional: under the new Subscribe/Publish primitive, the only errors Publish can actually return are programmer errors (empty SelfTopic from a misconfigured Authenticator, or MaxPublishesPerAction cap exceeded). Surfacing these loudly is a feature — the alternative (log-and-swallow) hides real configuration bugs that would otherwise silently break multi-tab sync. Added a comment at chat/main.go's first Publish call site explaining the rationale; the same propagation pattern is intentional at every other Publish site in the repo. No behavior change; comment-only. Refs livetemplate#415 (broadcast-action-redesign proposal) Refs livetemplate#429 (Phase 5 livetemplate-side PR) --- chat/main.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/chat/main.go b/chat/main.go index d28c121..e3b18d2 100644 --- a/chat/main.go +++ b/chat/main.go @@ -77,7 +77,11 @@ func (c *ChatController) Join(state ChatState, ctx *livetemplate.Context) (ChatS state.OnlineCount = c.countOnline() c.mu.Unlock() - // Tell other tabs someone joined + // Tell other tabs someone joined. We propagate Publish's error rather than + // log-and-swallow because the only errors it can return are programmer + // errors (empty SelfTopic from a misconfigured Authenticator, or the + // per-action publish cap exceeded). Surfacing them loudly is a feature. + // Same pattern applies to every Publish call site in this file. if err := ctx.Publish(ctx.SelfTopic(), "UserJoined", nil); err != nil { return state, err }