From c1a57d4c89a519839ad0c563e1489e6695e36188 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Tue, 12 May 2026 17:42:57 +0000 Subject: [PATCH] refactor: use explicit peer refresh broadcasts --- README.md | 2 +- docs/plans/improve-ui-ux.md | 4 ++-- landing-demo/README.md | 4 ++-- landing-demo/landing_demo_test.go | 10 +++++----- landing-demo/main.go | 24 ++++++++---------------- shared-notepad/main.go | 3 ++- shared-notepad/notepad.tmpl | 2 +- todos/controller.go | 30 +++++++++++++++++++++++++----- 8 files changed, 46 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 794cc36..21d43a0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Example applications demonstrating LiveTemplate usage with various features and 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/docs/plans/improve-ui-ux.md b/docs/plans/improve-ui-ux.md index 7ae4e02..c05fee5 100644 --- a/docs/plans/improve-ui-ux.md +++ b/docs/plans/improve-ui-ux.md @@ -470,7 +470,7 @@ Add a showcase section at the top of `README.md`, before the Progressive Complex 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 @@ -490,7 +490,7 @@ Record with screen capture (Kap, ffmpeg, or similar): 1. Split screen: two browser windows side by side, both at `localhost:8080` 2. Both logged in as `alice` 3. Demo sequence: - - Tab A: Add "Buy groceries" → appears in Tab B via Sync(), fade animation + - Tab A: Add "Buy groceries" → appears in Tab B via BroadcastAction, fade animation - Tab B: Toggle complete → strikethrough appears in Tab A, highlight flash - Tab A: Search "buy" → filters in Tab A only (independent per-tab) - Tab B: Delete with modal confirmation → disappears from Tab A diff --git a/landing-demo/README.md b/landing-demo/README.md index 11ac484..1b51b94 100644 --- a/landing-demo/README.md +++ b/landing-demo/README.md @@ -17,9 +17,9 @@ Same code, three transports: Two pieces enable it: - `Count int \`lvt:"persist"\`` makes the field session-store backed, so it survives reconnects and is visible to every connection in the same session group. -- The controller defines a `Sync()` method. The framework treats `Sync` as a reserved name: when present, it auto-dispatches it to peer connections after every action. Peer tabs reload `Count` from the SessionStore (because of the `persist` tag) and re-render with the new value. +- The controller explicitly calls `ctx.BroadcastAction(...)` after counter mutations so peer tabs receive the same counter action and re-render. -Without `Sync()`, peer tabs would only see the latest value on their own next action or a full reload. Without `persist`, peer tabs would re-render but with stale local state. +Without `BroadcastAction`, peer tabs would only see the latest value on their own next action or a full reload. Without `persist`, a full reload would not preserve the session count. ## Run locally diff --git a/landing-demo/landing_demo_test.go b/landing-demo/landing_demo_test.go index aa1bc19..f675c6e 100644 --- a/landing-demo/landing_demo_test.go +++ b/landing-demo/landing_demo_test.go @@ -213,11 +213,11 @@ func TestLandingDemoE2E(t *testing.T) { } }) - t.Run("Sync_Propagates_To_Peer_Tab", func(t *testing.T) { + t.Run("BroadcastAction_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 Sync() controller method is in main.go and is the - // hook the framework uses to dispatch peer-tab updates. But + // 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: // - chromedp.NewContext(parent) shares the browser allocator @@ -225,7 +225,7 @@ func TestLandingDemoE2E(t *testing.T) { // 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 Sync round-trip wasn't observed within 10s + // the peer-side broadcast 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 Sync 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 BroadcastAction 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 5461eeb..5b7aa47 100644 --- a/landing-demo/main.go +++ b/landing-demo/main.go @@ -1,9 +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 — each visitor has their own counter, and their own tabs stay in -// sync over WebSocket because Count is `lvt:"persist"` (session-store backed) -// AND the controller defines a Sync() method (which signals the framework -// to dispatch peer-tab updates after every action). +// state means each visitor has their own counter; explicit BroadcastAction +// calls keep their WebSocket-connected tabs in step. package main import ( @@ -21,31 +19,25 @@ type CounterState struct { Count int `json:"count" lvt:"persist"` } -func (c *CounterController) Increment(s CounterState, _ *livetemplate.Context) (CounterState, error) { +func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) { s.Count++ + ctx.BroadcastAction("Increment", nil) return s, nil } -func (c *CounterController) Decrement(s CounterState, _ *livetemplate.Context) (CounterState, error) { +func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) (CounterState, error) { // Clamp at zero — a public landing-page demo showing "Count: -7" // reads as broken even though the math is fine. if s.Count > 0 { s.Count-- } + ctx.BroadcastAction("Decrement", nil) return s, nil } -func (c *CounterController) Reset(s CounterState, _ *livetemplate.Context) (CounterState, error) { +func (c *CounterController) Reset(s CounterState, ctx *livetemplate.Context) (CounterState, error) { s.Count = 0 - return s, nil -} - -// Sync is the reserved method name that, when present on the controller, -// tells the framework to dispatch updates to peer connections in the same -// session group after every action. The body is a no-op return because Count -// is `lvt:"persist"` — the framework reloads it from the SessionStore -// before invoking Sync, so peer tabs see the latest persisted value. -func (c *CounterController) Sync(s CounterState, _ *livetemplate.Context) (CounterState, error) { + ctx.BroadcastAction("Reset", nil) return s, nil } diff --git a/shared-notepad/main.go b/shared-notepad/main.go index 8b4cf4d..ff9959e 100644 --- a/shared-notepad/main.go +++ b/shared-notepad/main.go @@ -45,6 +45,7 @@ func (c *NotepadController) Save(state NotepadState, ctx *livetemplate.Context) c.notes[ctx.UserID()] = state c.mu.Unlock() + ctx.BroadcastAction("Refresh", nil) return state, nil } @@ -56,7 +57,7 @@ func (c *NotepadController) Change(state NotepadState, ctx *livetemplate.Context return state, nil } -func (c *NotepadController) Sync(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) { +func (c *NotepadController) Refresh(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) { c.mu.RLock() if saved, ok := c.notes[ctx.UserID()]; ok { state.Content = saved.Content diff --git a/shared-notepad/notepad.tmpl b/shared-notepad/notepad.tmpl index 175a46b..170d76c 100644 --- a/shared-notepad/notepad.tmpl +++ b/shared-notepad/notepad.tmpl @@ -41,7 +41,7 @@
How it works
Demo credentials: any username, password demo diff --git a/todos/controller.go b/todos/controller.go index cd54e25..b9ef38d 100644 --- a/todos/controller.go +++ b/todos/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) {