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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** — `<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
4 changes: 2 additions & 2 deletions docs/plans/improve-ui-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** — `<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 All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions landing-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions landing-demo/landing_demo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,27 +213,27 @@ 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
// 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 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.
// Manual verification: load https://lt-landing-demo.fly.dev/ in
// 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) {
Expand Down
24 changes: 8 additions & 16 deletions landing-demo/main.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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
}

Expand Down
3 changes: 2 additions & 1 deletion shared-notepad/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion shared-notepad/notepad.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<header><strong>How it works</strong></header>
<ul>
<li><strong>BasicAuth</strong> — each user gets an isolated session (alice's notes are separate from bob's)</li>
<li><strong>Sync()</strong> — all tabs of the same user auto-sync (Save in one tab updates all)</li>
<li><strong>BroadcastAction</strong> — Save in one tab refreshes the others for the same user</li>
<li>Notes persist across page refreshes within the same server session</li>
</ul>
<small>Demo credentials: any username, password <strong>demo</strong></small>
Expand Down
30 changes: 25 additions & 5 deletions todos/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 Down
Loading