From d91a6b93a06f1d123a2f22518fb4e74fd138c394 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sun, 26 Jul 2026 07:10:39 +0000 Subject: [PATCH 1/2] feat(examples): greet-async example + e2e test; adopt livetemplate v0.22.0 (#521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a greet-async example that demonstrates the Async[S,R] + {{.lvt.Pending}} pattern — the same behavior as greet-loading-server but collapsed from two methods (Greet + GreetDone) to one via livetemplate.Async. The chromedp e2e test captures WS frames filtered by tree key (immune to heartbeat interleaving) and asserts the two-frame sequence: frame #1 carries "Loading..." (Pending=true), frame #2 carries the applied name with Pending cleared. Also bumps the livetemplate pin from v0.19.0 to v0.22.0 (Async API). Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01U1SVDXVmsZhhBFpUmPRV5j --- cmd/site/main.go | 8 ++ e2e/greet_async_test.go | 133 ++++++++++++++++++++++++++++++++ e2e/helpers_test.go | 119 ++++++++++++++++++++++++++++ examples/greet-async/greet.go | 30 +++++++ examples/greet-async/greet.tmpl | 37 +++++++++ examples/greet-async/handler.go | 47 +++++++++++ go.mod | 2 +- go.sum | 4 +- 8 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 e2e/greet_async_test.go create mode 100644 examples/greet-async/greet.go create mode 100644 examples/greet-async/greet.tmpl create mode 100644 examples/greet-async/handler.go diff --git a/cmd/site/main.go b/cmd/site/main.go index 86662a9..38fdd84 100644 --- a/cmd/site/main.go +++ b/cmd/site/main.go @@ -26,6 +26,7 @@ import ( draftform "github.com/livetemplate/docs/examples/draft-form" filetree "github.com/livetemplate/docs/examples/file-tree" "github.com/livetemplate/docs/examples/greet" + greetasync "github.com/livetemplate/docs/examples/greet-async" greetloading "github.com/livetemplate/docs/examples/greet-loading" greetloadingserver "github.com/livetemplate/docs/examples/greet-loading-server" greetnojs "github.com/livetemplate/docs/examples/greet-nojs" @@ -173,6 +174,13 @@ func main() { mux.Handle("/apps/greet-loading-server/", http.StripPrefix("/apps/greet-loading-server", greetloadingserver.Handler( livetemplate.WithAllowedOrigins(allowedOrigins), ))) + // greet-async is the Async+Pending variant: same server-owned loading + // behavior as greet-loading-server but collapsed to one method via + // livetemplate.Async and {{.lvt.Pending}}. WebSocket-enabled (Async + // dispatches the completion via DispatchChan on the event loop). + mux.Handle("/apps/greet-async/", http.StripPrefix("/apps/greet-async", greetasync.Handler( + livetemplate.WithAllowedOrigins(allowedOrigins), + ))) // greet-wall is the spine's climax (Steps 5-7) and the one shared, // WebSocket-enabled real-time app on the landing: per-user tab sync diff --git a/e2e/greet_async_test.go b/e2e/greet_async_test.go new file mode 100644 index 0000000..9368293 --- /dev/null +++ b/e2e/greet_async_test.go @@ -0,0 +1,133 @@ +package e2e + +import ( + "strings" + "testing" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +// TestGreetAsync exercises the Async + {{.lvt.Pending}} example: clicking +// the button triggers a two-frame WebSocket sequence: +// +// 1. Pending render: button shows "Loading..." with disabled + aria-busy +// 2. Completion render: headline shows the name, button reverts to "Say hi" +// +// Unlike TestSpineLoadingServerEmbed (which only checks final DOM), this test +// captures WS frames and asserts the intermediate pending state actually +// reached the client. +func TestGreetAsync(t *testing.T) { + ctx, cancel := newCtx(t) + defer cancel() + + consoleErrs := captureConsoleErrors(ctx) + ws := recordWSFrames(ctx) + + if err := chromedp.Run(ctx, network.Enable()); err != nil { + t.Fatalf("enable network domain: %v", err) + } + + const name = "Ada" + + if err := chromedp.Run(ctx, + chromedp.Navigate(baseURL()+"/apps/greet-async/"), + chromedp.WaitVisible(`h1`, chromedp.ByQuery), + ); err != nil { + t.Fatalf("navigate: %v\nconsole: %v", err, consoleErrs()) + } + + // Wait for WS to connect and the initial mount render to arrive. + if err := ws.waitForReceivedWithTreeCount(1, 10*time.Second); err != nil { + t.Fatalf("WS mount frame never arrived: %v\nframes:\n%s", err, ws.dump()) + } + + // Baseline: count tree-bearing frames before the action so we can + // isolate the pending + completion pair (ignores heartbeats/acks). + baselineCount := len(ws.receivedWithTree()) + + // Type name and click. + if err := chromedp.Run(ctx, + chromedp.SendKeys(`input[name="name"]`, name, chromedp.ByQuery), + chromedp.Click(`button.greet-btn`, chromedp.ByQuery), + ); err != nil { + t.Fatalf("send keys / click: %v\nconsole: %v", err, consoleErrs()) + } + + // Wait for the completion render (tree frame #2 after the action). The + // async work takes ~700ms, so allow up to 5s. Filtering by tree key + // ensures heartbeats/acks don't shift the index. + if err := ws.waitForReceivedWithTreeCount(baselineCount+2, 5*time.Second); err != nil { + t.Fatalf("expected 2 tree frames after action (pending + completion): %v\nframes:\n%s", + err, ws.dump()) + } + + actionFrames := ws.receivedWithTreeSince(baselineCount) + + // --- Frame #1: pending render --- + // The pending frame's tree should contain "Loading..." (the button text + // when .lvt.Pending is true). + if len(actionFrames) < 1 { + t.Fatal("no frames after action") + } + pendingData := actionFrames[0].Data + if !strings.Contains(pendingData, "Loading...") { + t.Errorf("frame #1 (pending) should contain \"Loading...\", got:\n%s", truncateForLog(pendingData)) + } + + // --- Frame #2: completion render --- + // The completion frame should contain the applied name and NOT contain + // "Loading..." (pending cleared). + if len(actionFrames) < 2 { + t.Fatal("only 1 frame after action, expected 2 (pending + completion)") + } + completionData := actionFrames[1].Data + if !strings.Contains(completionData, name) { + t.Errorf("frame #2 (completion) should contain %q, got:\n%s", name, truncateForLog(completionData)) + } + if strings.Contains(completionData, "Loading...") { + t.Errorf("frame #2 (completion) should NOT contain \"Loading...\" (pending should be cleared)") + } + + // --- Final DOM state --- + var headline, button, disabled, busy string + if err := chromedp.Run(ctx, + chromedp.Text(`h1`, &headline, chromedp.ByQuery), + chromedp.Text(`button.greet-btn`, &button, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelector('button.greet-btn')?.getAttribute('disabled') || ''`, &disabled), + chromedp.Evaluate(`document.querySelector('button.greet-btn')?.getAttribute('aria-busy') || ''`, &busy), + ); err != nil { + t.Fatalf("DOM query: %v\nconsole: %v", err, consoleErrs()) + } + + if !strings.Contains(headline, name) { + t.Errorf("headline = %q, want to contain %q", headline, name) + } + if strings.TrimSpace(button) != "Say hi" { + t.Errorf("button text = %q, want \"Say hi\" after completion", button) + } + if disabled != "" { + t.Errorf("button still disabled after completion") + } + if busy != "" { + t.Errorf("button still aria-busy after completion") + } + + // No console errors. + if errs := consoleErrs(); len(errs) > 0 { + t.Errorf("browser console errors: %v", errs) + } + + // Dump frames on failure for diagnostics. + if t.Failed() { + t.Logf("WS frames:\n%s", ws.dump()) + } +} + +func truncateForLog(s string) string { + if len(s) <= 500 { + return s + } + return s[:500] + "..." +} diff --git a/e2e/helpers_test.go b/e2e/helpers_test.go index 96808fa..699ac28 100644 --- a/e2e/helpers_test.go +++ b/e2e/helpers_test.go @@ -2,8 +2,13 @@ package e2e import ( "context" + "encoding/json" + "fmt" "strings" + "sync" + "time" + "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" ) @@ -24,3 +29,117 @@ func captureConsoleErrors(ctx context.Context) func() []string { }) return func() []string { return errs } } + +type wsFrame struct { + Direction string + Data string + Parsed map[string]any +} + +type wsRecorder struct { + mu sync.Mutex + frames []wsFrame +} + +func recordWSFrames(ctx context.Context) *wsRecorder { + r := &wsRecorder{} + chromedp.ListenTarget(ctx, func(ev interface{}) { + switch ev := ev.(type) { + case *network.EventWebSocketFrameReceived: + r.mu.Lock() + defer r.mu.Unlock() + f := wsFrame{Direction: "received", Data: ev.Response.PayloadData} + if json.Valid([]byte(f.Data)) { + _ = json.Unmarshal([]byte(f.Data), &f.Parsed) + } + r.frames = append(r.frames, f) + case *network.EventWebSocketFrameSent: + r.mu.Lock() + defer r.mu.Unlock() + f := wsFrame{Direction: "sent", Data: ev.Response.PayloadData} + if json.Valid([]byte(f.Data)) { + _ = json.Unmarshal([]byte(f.Data), &f.Parsed) + } + r.frames = append(r.frames, f) + } + }) + return r +} + +func (r *wsRecorder) received() []wsFrame { + r.mu.Lock() + defer r.mu.Unlock() + var out []wsFrame + for _, f := range r.frames { + if f.Direction == "received" { + out = append(out, f) + } + } + return out +} + +func (r *wsRecorder) receivedWithTree() []wsFrame { + r.mu.Lock() + defer r.mu.Unlock() + var out []wsFrame + for _, f := range r.frames { + if f.Direction == "received" { + if _, ok := f.Parsed["tree"]; ok { + out = append(out, f) + } + } + } + return out +} + +func (r *wsRecorder) receivedSince(n int) []wsFrame { + all := r.received() + if n >= len(all) { + return nil + } + return all[n:] +} + +func (r *wsRecorder) receivedWithTreeSince(n int) []wsFrame { + all := r.receivedWithTree() + if n >= len(all) { + return nil + } + return all[n:] +} + +func (r *wsRecorder) waitForReceivedCount(want int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if len(r.received()) >= want { + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("timeout: got %d received frames, want >= %d", len(r.received()), want) +} + +func (r *wsRecorder) waitForReceivedWithTreeCount(want int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if len(r.receivedWithTree()) >= want { + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("timeout: got %d tree frames, want >= %d", len(r.receivedWithTree()), want) +} + +func (r *wsRecorder) dump() string { + r.mu.Lock() + defer r.mu.Unlock() + var b strings.Builder + for i, f := range r.frames { + data := f.Data + if len(data) > 200 { + data = data[:200] + "..." + } + fmt.Fprintf(&b, "[%d] %s: %s\n", i, f.Direction, data) + } + return b.String() +} diff --git a/examples/greet-async/greet.go b/examples/greet-async/greet.go new file mode 100644 index 0000000..812a2ee --- /dev/null +++ b/examples/greet-async/greet.go @@ -0,0 +1,30 @@ +package greetasync + +import ( + "context" + "strings" + "time" + + "github.com/livetemplate/livetemplate" +) + +type State struct { + Name string +} + +type Controller struct{} + +func (c *Controller) Greet(s State, ctx *livetemplate.Context) (State, error) { + name := strings.TrimSpace(ctx.GetString("name")) + livetemplate.Async(ctx, + func(context.Context) (string, error) { + time.Sleep(700 * time.Millisecond) + return name, nil + }, + func(s State, name string, err error) (State, error) { + s.Name = name + return s, nil + }, + ) + return s, nil +} diff --git a/examples/greet-async/greet.tmpl b/examples/greet-async/greet.tmpl new file mode 100644 index 0000000..76328b2 --- /dev/null +++ b/examples/greet-async/greet.tmpl @@ -0,0 +1,37 @@ + + + + + Hello + + + + + + +
+

Hello, {{.Name}}

+
+ + +
+
+ + diff --git a/examples/greet-async/handler.go b/examples/greet-async/handler.go new file mode 100644 index 0000000..18a40cb --- /dev/null +++ b/examples/greet-async/handler.go @@ -0,0 +1,47 @@ +package greetasync + +import ( + "embed" + "log" + "net/http" + "os" + "path/filepath" + "sync" + + "github.com/livetemplate/livetemplate" +) + +//go:embed greet.tmpl +var templateFS embed.FS + +var ( + tmplPath string + tmplOnce sync.Once +) + +func extractTemplate() string { + tmplOnce.Do(func() { + dir, err := os.MkdirTemp("", "greet-async-tmpl-*") + if err != nil { + log.Fatalf("greet-async: mkdtemp: %v", err) + } + data, err := templateFS.ReadFile("greet.tmpl") + if err != nil { + log.Fatalf("greet-async: read embedded tmpl: %v", err) + } + tmplPath = filepath.Join(dir, "greet.tmpl") + if err := os.WriteFile(tmplPath, data, 0o644); err != nil { + log.Fatalf("greet-async: write tmpl: %v", err) + } + }) + return tmplPath +} + +func Handler(opts ...livetemplate.Option) http.Handler { + baseOpts := []livetemplate.Option{ + livetemplate.WithParseFiles(extractTemplate()), + livetemplate.WithAuthenticator(&livetemplate.AnonymousAuthenticator{}), + } + tmpl := livetemplate.Must(livetemplate.New("greet-async", append(baseOpts, opts...)...)) + return tmpl.Handle(&Controller{}, livetemplate.AsState(&State{Name: "there"})) +} diff --git a/go.mod b/go.mod index dc53687..09c0d15 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.2 github.com/gorilla/websocket v1.5.3 - github.com/livetemplate/livetemplate v0.19.0 + github.com/livetemplate/livetemplate v0.22.0 github.com/livetemplate/lvt v0.2.0 github.com/livetemplate/lvt/components v0.1.8 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 4622a2e..60e1b00 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,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.19.0 h1:tLuF6vqDS5FE7ggwMjie6hmxLY2C94glPl2nAqFceNo= -github.com/livetemplate/livetemplate v0.19.0/go.mod h1:xkmgckEpdoYTqretGpaWfh8uqvjsBBLBJYd84n2O5QQ= +github.com/livetemplate/livetemplate v0.22.0 h1:7l2oDUDOm7Srl8oaySHtxElTsf6l490KLosq9eg13OU= +github.com/livetemplate/livetemplate v0.22.0/go.mod h1:xkmgckEpdoYTqretGpaWfh8uqvjsBBLBJYd84n2O5QQ= github.com/livetemplate/lvt v0.2.0 h1:BQkZHKwFIACeq//L92cxjWdF7c4b5qjl9fxHnvbgQ6U= github.com/livetemplate/lvt v0.2.0/go.mod h1:JPz+9CHGq4nTHVNH+rQAZxEapL9F+m9zyhzJJ8pYbzQ= github.com/livetemplate/lvt/components v0.1.8 h1:9LNwmhrcoqxtnUQDYC1+sh2X9m9Yoqay0tDs8qoj9Xc= From 365560f7ccd926736d3e854578e163199f6cee49 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sun, 26 Jul 2026 07:24:12 +0000 Subject: [PATCH 2/2] fix: remove dead code and unused err param in greet-async example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove received(), receivedSince(), waitForReceivedCount() from helpers_test.go — superseded by tree-key-filtered variants, no callers - Name unused err param as _ in apply closure to match documented error-propagation pattern Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01U1SVDXVmsZhhBFpUmPRV5j --- e2e/helpers_test.go | 31 ------------------------------- examples/greet-async/greet.go | 2 +- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/e2e/helpers_test.go b/e2e/helpers_test.go index 699ac28..59db4a2 100644 --- a/e2e/helpers_test.go +++ b/e2e/helpers_test.go @@ -66,18 +66,6 @@ func recordWSFrames(ctx context.Context) *wsRecorder { return r } -func (r *wsRecorder) received() []wsFrame { - r.mu.Lock() - defer r.mu.Unlock() - var out []wsFrame - for _, f := range r.frames { - if f.Direction == "received" { - out = append(out, f) - } - } - return out -} - func (r *wsRecorder) receivedWithTree() []wsFrame { r.mu.Lock() defer r.mu.Unlock() @@ -92,14 +80,6 @@ func (r *wsRecorder) receivedWithTree() []wsFrame { return out } -func (r *wsRecorder) receivedSince(n int) []wsFrame { - all := r.received() - if n >= len(all) { - return nil - } - return all[n:] -} - func (r *wsRecorder) receivedWithTreeSince(n int) []wsFrame { all := r.receivedWithTree() if n >= len(all) { @@ -108,17 +88,6 @@ func (r *wsRecorder) receivedWithTreeSince(n int) []wsFrame { return all[n:] } -func (r *wsRecorder) waitForReceivedCount(want int, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if len(r.received()) >= want { - return nil - } - time.Sleep(50 * time.Millisecond) - } - return fmt.Errorf("timeout: got %d received frames, want >= %d", len(r.received()), want) -} - func (r *wsRecorder) waitForReceivedWithTreeCount(want int, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { diff --git a/examples/greet-async/greet.go b/examples/greet-async/greet.go index 812a2ee..cef91c9 100644 --- a/examples/greet-async/greet.go +++ b/examples/greet-async/greet.go @@ -21,7 +21,7 @@ func (c *Controller) Greet(s State, ctx *livetemplate.Context) (State, error) { time.Sleep(700 * time.Millisecond) return name, nil }, - func(s State, name string, err error) (State, error) { + func(s State, name string, _ error) (State, error) { s.Name = name return s, nil },