From 1cece405361896ad6ed883be6ec1c6f75182b58b Mon Sep 17 00:00:00 2001 From: Adnaan Date: Tue, 14 Apr 2026 22:10:03 +0530 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20patterns=20example=20Session=203?= =?UTF-8?q?=20(patterns=20#14-16)=20=E2=80=94=20loading=20&=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the three Loading & Progress patterns from the patterns example proposal (livetemplate#333): - Pattern #14: Lazy Loading - Pattern #15: Progress Bar - Pattern #16: Async Operations All three rely on `session.TriggerAction()` from a background goroutine to push state updates to the client without polling. None of them worked against the shipping livetemplate v0.8.17 because `ctx.Session()` returned nil — the Session interface was declared but never wired into any production code path. The library gap is fixed in livetemplate#336 (merged as 4883481). ## What's new **state_loading.go** — three state structs (LazyLoadState, ProgressBarState, AsyncOpsState) following the Title/Category-first convention from Sessions 1 and 2. **handlers_loading.go** — three controllers + handler factories: - `LazyLoadController.OnConnect` spawns a 2s goroutine that calls TriggerAction("dataLoaded", ...) once. Reload action re-triggers the same path. Mount() guards on `ctx.Action() == ""` so the Reload POST doesn't reset state. - `ProgressBarController.Start` spawns a goroutine that ticks TriggerAction("updateProgress", {progress: int}) every 500ms for 10 iterations. UpdateProgress reads the int via ctx.GetInt and emits a `success` flash on completion. The `Running` flag prevents double-click stacking within a session. - `AsyncOpsController.Fetch` spawns a goroutine that sleeps 2s and calls TriggerAction("fetchResult", ...) with either success or error data (~33% simulated failure rate via math/rand). FetchResult routes to "success" / "error" via ctx.GetBool and emits the matching flash. **templates/loading/{lazy-loading,progress-bar,async-operations}.tmpl** — Pico CSS markup using ``, `
`, ``, and the FlashTag helper. The async-operations template uses {{with}} on .Result and .Error to drop the redundant {{if eq .Status}} boilerplate around the FlashTag calls — FlashTag is self-guarding when the flash key isn't set, and {{with}} on the mutually-exclusive Result/Error strings handles the success/error rendering branch without referencing .Status. **main.go** — three new mux.Handle registrations under /patterns/loading/. **data.go** — flips Implemented:true on the three Loading & Progress entries in allPatterns(). The index template iterates allPatterns() data-driven, so no index.tmpl edits are required. **patterns_test.go** — three new test functions: - TestLazyLoading: Initial spinner, data arrives via push, Reload produces fresh content with a different timestamp. - TestProgressBar: Initial state, Start runs to completion (ticks visible, success flash, "Run Again" button), Run Again restarts. - TestAsyncOperations: Initial state, Fetch transitions through loading → success/error (random outcome accepted), matching flash element verified. All tests use condition-based waits (e2etest.WaitForText, WaitFor with real JS predicates) and the shared setupTest fixture. None use chromedp.Sleep. **login/login_test.go** — adds Server_Welcome_Message_via_WebSocket_Push subtest to TestLoginE2E. Before the library fix, the login example's sendWelcomeMessage goroutine silently no-op'd because ctx.Session() returned nil; the existing "Successful Login" test passed only because it asserted on the static "Welcome, testuser!" template literal, not the server-pushed timestamped message. The new subtest waits for "pushed from the server" text in the element with a 5s timeout — without the library fix the goroutine never fires and this assertion times out. ## Dependencies Requires livetemplate >= v0.8.18 (the next release after the merged fix in livetemplate#336). go.mod will be bumped in a follow-up commit on this branch once the library release lands. Until then, the branch builds against the local main checkout via go.work for manual testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- login/login_test.go | 21 ++ patterns/data.go | 6 +- patterns/handlers_loading.go | 204 ++++++++++++++++++ patterns/main.go | 5 + patterns/patterns_test.go | 194 +++++++++++++++++ patterns/state_loading.go | 28 +++ .../templates/loading/async-operations.tmpl | 16 ++ patterns/templates/loading/lazy-loading.tmpl | 14 ++ patterns/templates/loading/progress-bar.tmpl | 20 ++ 9 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 patterns/handlers_loading.go create mode 100644 patterns/state_loading.go create mode 100644 patterns/templates/loading/async-operations.tmpl create mode 100644 patterns/templates/loading/lazy-loading.tmpl create mode 100644 patterns/templates/loading/progress-bar.tmpl diff --git a/login/login_test.go b/login/login_test.go index cfd0d73..709d6d3 100644 --- a/login/login_test.go +++ b/login/login_test.go @@ -195,6 +195,27 @@ func TestLoginE2E(t *testing.T) { t.Log("✅ Successful login verified") }) + // Regression guard: before livetemplate's Session.TriggerAction fix, + // ctx.Session() returned nil inside OnConnect, so sendWelcomeMessage + // silently no-op'd. The page-literal "Welcome, testuser!" (tested above) + // is template-rendered and would still be present even with a broken + // Session, so only an explicit check for the server-push payload catches + // the regression. sendWelcomeMessage sleeps 500ms then calls + // session.TriggerAction("serverWelcome", {"message": "Welcome testuser! + // This message was pushed from the server at HH:MM:SS"}), which + // ServerWelcome renders into the ServerMessage element. + t.Run("Server Welcome Message via WebSocket Push", func(t *testing.T) { + err := chromedp.Run(ctx, + e2etest.WaitForText(`ins`, "pushed from the server", 5*time.Second), + ) + if err != nil { + var body string + _ = chromedp.Run(ctx, chromedp.OuterHTML(`body`, &body, chromedp.ByQuery)) + t.Fatalf("Server welcome message did not arrive via WebSocket push within 5s: %v\n=== body ===\n%s", err, body[:min(len(body), 800)]) + } + t.Log("✅ Server-pushed welcome message verified") + }) + t.Run("Logout via Form Submit", func(t *testing.T) { var html string diff --git a/patterns/data.go b/patterns/data.go index 64f5e7b..81da4ec 100644 --- a/patterns/data.go +++ b/patterns/data.go @@ -258,9 +258,9 @@ func allPatterns() []PatternCategory { { Name: "Loading & Progress", Patterns: []PatternLink{ - {Name: "Lazy Loading", Path: "/patterns/loading/lazy-loading", Description: "Load content after page render via server push"}, - {Name: "Progress Bar", Path: "/patterns/loading/progress-bar", Description: "WebSocket-pushed progress updates"}, - {Name: "Async Operations", Path: "/patterns/loading/async-operations", Description: "Loading/success/error state machine"}, + {Name: "Lazy Loading", Path: "/patterns/loading/lazy-loading", Description: "Load content after page render via server push", Implemented: true}, + {Name: "Progress Bar", Path: "/patterns/loading/progress-bar", Description: "WebSocket-pushed progress updates", Implemented: true}, + {Name: "Async Operations", Path: "/patterns/loading/async-operations", Description: "Loading/success/error state machine", Implemented: true}, }, }, { diff --git a/patterns/handlers_loading.go b/patterns/handlers_loading.go new file mode 100644 index 0000000..ea2030b --- /dev/null +++ b/patterns/handlers_loading.go @@ -0,0 +1,204 @@ +package main + +import ( + "math/rand" + "net/http" + "slices" + "time" + + "github.com/livetemplate/livetemplate" +) + +// --- Pattern #14: Lazy Loading --- + +// LazyLoadController spawns a goroutine on OnConnect that pushes the lazily- +// loaded payload via session.TriggerAction after a simulated delay. If the +// client reconnects after the payload has already arrived, OnConnect is a +// no-op so the goroutine does not fire a second time. +type LazyLoadController struct{} + +// lazyLoadDelay is how long the simulated "slow API" takes before data arrives. +const lazyLoadDelay = 2 * time.Second + +func (c *LazyLoadController) Mount(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { + // Guard: Mount also fires on POST actions (e.g., Reload). Without this, + // the POST would reset Data/Loading and stomp on the action's own return. + if ctx.Action() == "" { + state.Loading = true + state.Data = "" + } + return state, nil +} + +func (c *LazyLoadController) OnConnect(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { + if !state.Loading { + return state, nil + } + session := ctx.Session() + if session == nil { + return state, nil + } + go func() { + time.Sleep(lazyLoadDelay) + _ = session.TriggerAction("dataLoaded", map[string]interface{}{ + "data": "Content loaded lazily at " + time.Now().Format("15:04:05"), + }) + }() + return state, nil +} + +func (c *LazyLoadController) DataLoaded(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { + state.Data = ctx.GetString("data") + state.Loading = false + return state, nil +} + +func (c *LazyLoadController) Reload(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { + state.Loading = true + state.Data = "" + session := ctx.Session() + if session == nil { + return state, nil + } + go func() { + time.Sleep(lazyLoadDelay) + _ = session.TriggerAction("dataLoaded", map[string]interface{}{ + "data": "Content reloaded at " + time.Now().Format("15:04:05"), + }) + }() + return state, nil +} + +func lazyLoadingHandler(baseOpts []livetemplate.Option) http.Handler { + opts := append(slices.Clone(baseOpts), + livetemplate.WithParseFiles("templates/layout.tmpl", "templates/loading/lazy-loading.tmpl"), + ) + tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) + return tmpl.Handle(&LazyLoadController{}, livetemplate.AsState(&LazyLoadState{ + Title: "Lazy Loading", + Category: "Loading & Progress", + })) +} + +// --- Pattern #15: Progress Bar --- + +// ProgressBarController drives a bounded goroutine that ticks progress from +// 10% to 100% in 10% increments every 500ms. The goroutine exits cleanly if +// session.TriggerAction returns an error (session disconnected) — this is the +// canonical cancellation pattern documented in the Server Push pattern (#31). +type ProgressBarController struct{} + +const ( + progressStep = 10 + progressTickRate = 500 * time.Millisecond +) + +func (c *ProgressBarController) Start(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) { + // Per-session guard against double-click stacking two goroutines. + if state.Running { + return state, nil + } + state.Running = true + state.Done = false + state.Progress = 0 + session := ctx.Session() + if session == nil { + return state, nil + } + go func() { + for i := progressStep; i <= 100; i += progressStep { + time.Sleep(progressTickRate) + if err := session.TriggerAction("updateProgress", map[string]interface{}{ + "progress": i, + }); err != nil { + return // Session disconnected — stop cleanly. + } + } + }() + return state, nil +} + +func (c *ProgressBarController) UpdateProgress(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) { + state.Progress = ctx.GetInt("progress") + if state.Progress >= 100 { + state.Running = false + state.Done = true + ctx.SetFlash("success", "Job complete") + } + return state, nil +} + +func progressBarHandler(baseOpts []livetemplate.Option) http.Handler { + opts := append(slices.Clone(baseOpts), + livetemplate.WithParseFiles("templates/layout.tmpl", "templates/loading/progress-bar.tmpl"), + ) + tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) + return tmpl.Handle(&ProgressBarController{}, livetemplate.AsState(&ProgressBarState{ + Title: "Progress Bar", + Category: "Loading & Progress", + })) +} + +// --- Pattern #16: Async Operations --- + +// AsyncOpsController implements a loading/success/error state machine. The +// Fetch action transitions to "loading" synchronously, then a goroutine waits +// and pushes a "fetchResult" action with either a success payload or an error +// payload. Demonstrates the minimal state-machine shape you'd use for any +// async RPC (database query, HTTP API, job queue, etc.). +type AsyncOpsController struct{} + +const asyncFetchDelay = 2 * time.Second + +func (c *AsyncOpsController) Fetch(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) { + state.Status = "loading" + state.Result = "" + state.Error = "" + session := ctx.Session() + if session == nil { + return state, nil + } + go func() { + time.Sleep(asyncFetchDelay) + // Simulated ~33% failure rate. Non-deterministic by design — tests + // must assert {success OR error}, not a specific branch. + if rand.Intn(3) == 0 { + _ = session.TriggerAction("fetchResult", map[string]interface{}{ + "success": false, + "error": "Connection timed out", + }) + } else { + _ = session.TriggerAction("fetchResult", map[string]interface{}{ + "success": true, + "result": "Data fetched successfully at " + time.Now().Format("15:04:05"), + }) + } + }() + return state, nil +} + +func (c *AsyncOpsController) FetchResult(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) { + if ctx.GetBool("success") { + state.Status = "success" + state.Result = ctx.GetString("result") + state.Error = "" + ctx.SetFlash("success", "Fetch complete") + } else { + state.Status = "error" + state.Error = ctx.GetString("error") + state.Result = "" + ctx.SetFlash("error", "Fetch failed") + } + return state, nil +} + +func asyncOperationsHandler(baseOpts []livetemplate.Option) http.Handler { + opts := append(slices.Clone(baseOpts), + livetemplate.WithParseFiles("templates/layout.tmpl", "templates/loading/async-operations.tmpl"), + ) + tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) + return tmpl.Handle(&AsyncOpsController{}, livetemplate.AsState(&AsyncOpsState{ + Title: "Async Operations", + Category: "Loading & Progress", + })) +} diff --git a/patterns/main.go b/patterns/main.go index 049d208..ff0455d 100644 --- a/patterns/main.go +++ b/patterns/main.go @@ -85,6 +85,11 @@ func main() { mux.Handle("/patterns/search/active-search", activeSearchHandler(baseOpts)) mux.Handle("/patterns/search/url-filters", urlFiltersHandler(baseOpts)) + // Category: Loading & Progress (#14–#16) + mux.Handle("/patterns/loading/lazy-loading", lazyLoadingHandler(baseOpts)) + mux.Handle("/patterns/loading/progress-bar", progressBarHandler(baseOpts)) + mux.Handle("/patterns/loading/async-operations", asyncOperationsHandler(baseOpts)) + // Client library and CSS (dev mode) if localClient := os.Getenv("LVT_LOCAL_CLIENT"); localClient != "" { mux.HandleFunc("/livetemplate-client.js", func(w http.ResponseWriter, r *http.Request) { diff --git a/patterns/patterns_test.go b/patterns/patterns_test.go index d341820..cdeb840 100644 --- a/patterns/patterns_test.go +++ b/patterns/patterns_test.go @@ -1457,3 +1457,197 @@ func TestInfiniteScroll(t *testing.T) { } }) } + +// --- Session 3: Loading & Progress --- + +func TestLazyLoading(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + ctx, cancel, serverPort := setupTest(t) + defer cancel() + + url := e2etest.GetChromeTestURL(serverPort) + "/patterns/loading/lazy-loading" + + t.Run("Initial_Load_Shows_Spinner", func(t *testing.T) { + // The page should render immediately with the spinner; the content + // blockquote must be absent until the goroutine fires (~2s later). + var hasBlockquote bool + err := chromedp.Run(ctx, + chromedp.Navigate(url), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`p[aria-busy="true"]`, chromedp.ByQuery), + e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), + chromedp.Evaluate(`!!document.querySelector('blockquote')`, &hasBlockquote), + ) + if err != nil { + t.Fatalf("Initial load failed: %v", err) + } + if hasBlockquote { + t.Error("Blockquote should not be present while still loading") + } + }) + + t.Run("Data_Arrives_Via_Server_Push", func(t *testing.T) { + // The goroutine sleeps 2s then pushes via TriggerAction. 5s timeout + // is generous. After arrival, the spinner must be gone. + var hasSpinner bool + err := chromedp.Run(ctx, + e2etest.WaitForText(`blockquote`, "Content loaded lazily", 5*time.Second), + chromedp.Evaluate(`!!document.querySelector('p[aria-busy="true"]')`, &hasSpinner), + ) + if err != nil { + t.Fatalf("Data did not arrive: %v", err) + } + if hasSpinner { + t.Error("Spinner should be gone after data arrives") + } + }) + + t.Run("Reload_Refetches_Fresh_Content", func(t *testing.T) { + // Click Reload; spinner reappears; new content arrives with a + // different timestamp (proves a second goroutine ran, not a cached value). + var firstContent, secondContent string + err := chromedp.Run(ctx, + chromedp.Text(`blockquote`, &firstContent, chromedp.ByQuery), + chromedp.Click(`button[name="reload"]`, chromedp.ByQuery), + chromedp.WaitVisible(`p[aria-busy="true"]`, chromedp.ByQuery), + e2etest.WaitForText(`blockquote`, "Content reloaded", 5*time.Second), + chromedp.Text(`blockquote`, &secondContent, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Reload failed: %v", err) + } + if !strings.Contains(secondContent, "Content reloaded") { + t.Errorf("Reload did not produce fresh content: %q", secondContent) + } + if firstContent == secondContent { + t.Errorf("Reload returned identical content (expected different timestamp): %q", secondContent) + } + }) + + runStandardSubtests(t, ctx, false, "Lazy Loading — page showing a blockquote with lazily-loaded content and a secondary 'Reload' button below") +} + +func TestProgressBar(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + ctx, cancel, serverPort := setupTest(t) + defer cancel() + + url := e2etest.GetChromeTestURL(serverPort) + "/patterns/loading/progress-bar" + + t.Run("Initial_Load", func(t *testing.T) { + var hasProgress bool + err := chromedp.Run(ctx, + chromedp.Navigate(url), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`button[name="start"]`, chromedp.ByQuery), + e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), + chromedp.Evaluate(`!!document.querySelector('progress')`, &hasProgress), + ) + if err != nil { + t.Fatalf("Initial load failed: %v", err) + } + if hasProgress { + t.Error(" should not be present before Start is clicked") + } + }) + + t.Run("Start_Runs_To_Completion", func(t *testing.T) { + // Click Start; progress element appears and ticks up. Goroutine runs + // 10 × 500ms = 5s, so wait 10s for completion and the success flash. + err := chromedp.Run(ctx, + chromedp.Click(`button[name="start"]`, chromedp.ByQuery), + e2etest.WaitFor(`!!document.querySelector('progress')`, 3*time.Second), + // Progress element starts ticking above 0. + e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value > 0`, 3*time.Second), + // Run Again button indicates the Done state. + e2etest.WaitForText(`button`, "Run Again", 10*time.Second), + e2etest.WaitForText(`output[data-flash="success"]`, "Job complete", 3*time.Second), + ) + if err != nil { + t.Fatalf("Progress bar did not complete: %v", err) + } + }) + + t.Run("Run_Again_Restarts_Timer", func(t *testing.T) { + // The Run Again button starts the timer again. Progress must begin + // from below 100 and climb back to completion. + err := chromedp.Run(ctx, + chromedp.Click(`button[name="start"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value > 0 && document.querySelector('progress').value < 100`, 3*time.Second), + e2etest.WaitForText(`button`, "Run Again", 10*time.Second), + ) + if err != nil { + t.Fatalf("Run Again failed: %v", err) + } + }) + + runStandardSubtests(t, ctx, false, "Progress Bar — completed state showing a full progress bar, a 'Job complete' success flash below it, and a 'Run Again' button") +} + +func TestAsyncOperations(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + ctx, cancel, serverPort := setupTest(t) + defer cancel() + + url := e2etest.GetChromeTestURL(serverPort) + "/patterns/loading/async-operations" + + t.Run("Initial_Load", func(t *testing.T) { + var hasResult bool + err := chromedp.Run(ctx, + chromedp.Navigate(url), + e2etest.WaitForWebSocketReady(5*time.Second), + e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 3*time.Second), + e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), + chromedp.Evaluate(`!!document.querySelector('blockquote') || !!document.querySelector('mark')`, &hasResult), + ) + if err != nil { + t.Fatalf("Initial load failed: %v", err) + } + if hasResult { + t.Error("Result/error display should not be present before Fetch is clicked") + } + }) + + t.Run("Fetch_Transitions_Through_Loading_To_Result", func(t *testing.T) { + // Click Fetch → transient loading state → final success OR error. + // The branch is random (~33% error rate). Tests must tolerate either. + err := chromedp.Run(ctx, + chromedp.Click(`button[name="fetch"]`, chromedp.ByQuery), + // Loading state: button shows "Fetching..." and aria-busy. + e2etest.WaitForText(`button[name="fetch"]`, "Fetching...", 3*time.Second), + // Final state: either
(success) or (error). + e2etest.WaitFor(`!!document.querySelector('blockquote') || !!document.querySelector('mark')`, 5*time.Second), + // Button must re-enable (exits "loading" status). + e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 3*time.Second), + ) + if err != nil { + t.Fatalf("Async flow did not complete: %v", err) + } + // Exactly one of success or error must be present, plus the matching flash. + var outcome string + _ = chromedp.Run(ctx, chromedp.Evaluate(`(() => { + if (document.querySelector('blockquote')) return 'success'; + if (document.querySelector('mark')) return 'error'; + return 'none'; + })()`, &outcome)) + if outcome == "none" { + t.Fatal("No outcome (neither success nor error) rendered") + } + var hasFlash bool + _ = chromedp.Run(ctx, chromedp.Evaluate(`!!document.querySelector('output[data-flash="`+outcome+`"]')`, &hasFlash)) + if !hasFlash { + t.Errorf("Outcome %q: expected matching flash output[data-flash=%q]", outcome, outcome) + } + }) + + runStandardSubtests(t, ctx, false, "Async Operations — 'Fetch Data' button followed by either a success flash and blockquote with fetch result, or an error flash and mark element with an error message") +} diff --git a/patterns/state_loading.go b/patterns/state_loading.go new file mode 100644 index 0000000..0ef8100 --- /dev/null +++ b/patterns/state_loading.go @@ -0,0 +1,28 @@ +package main + +// LazyLoadState holds the state for the Lazy Loading pattern (#14). +type LazyLoadState struct { + Title string + Category string + Loading bool + Data string +} + +// ProgressBarState holds the state for the Progress Bar pattern (#15). +type ProgressBarState struct { + Title string + Category string + Progress int + Running bool + Done bool +} + +// AsyncOpsState holds the state for the Async Operations pattern (#16). +// Status is a simple state machine: "" (idle), "loading", "success", "error". +type AsyncOpsState struct { + Title string + Category string + Status string + Result string + Error string +} diff --git a/patterns/templates/loading/async-operations.tmpl b/patterns/templates/loading/async-operations.tmpl new file mode 100644 index 0000000..63f2fd9 --- /dev/null +++ b/patterns/templates/loading/async-operations.tmpl @@ -0,0 +1,16 @@ +{{define "content"}} +
+

Async Operations

+

A loading → success / error state machine. ~33% simulated failure rate on each fetch.

+
+ +
+ {{.lvt.FlashTag "success"}} + {{.lvt.FlashTag "error"}} + {{with .Result}}
{{.}}
{{end}} + {{with .Error}}

{{.}}

{{end}} +
+{{end}} diff --git a/patterns/templates/loading/lazy-loading.tmpl b/patterns/templates/loading/lazy-loading.tmpl new file mode 100644 index 0000000..2a6aec8 --- /dev/null +++ b/patterns/templates/loading/lazy-loading.tmpl @@ -0,0 +1,14 @@ +{{define "content"}} +
+

Lazy Loading

+

The page renders immediately; content arrives from the server ~2s later via a goroutine pushing session.TriggerAction.

+ {{if .Loading}} +

Loading content...

+ {{else}} +
{{.Data}}
+
+ +
+ {{end}} +
+{{end}} diff --git a/patterns/templates/loading/progress-bar.tmpl b/patterns/templates/loading/progress-bar.tmpl new file mode 100644 index 0000000..400143d --- /dev/null +++ b/patterns/templates/loading/progress-bar.tmpl @@ -0,0 +1,20 @@ +{{define "content"}} +
+

Progress Bar

+

A background goroutine ticks progress from 10% to 100% via WebSocket pushes — no polling.

+ {{if .Running}} + +

{{.Progress}}% complete

+ {{else if .Done}} + + {{.lvt.FlashTag "success"}} +
+ +
+ {{else}} +
+ +
+ {{end}} +
+{{end}} From 6b70b1dde9554f55358dcba52ee97af84a11b9e5 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Tue, 14 Apr 2026 23:07:37 +0530 Subject: [PATCH 02/17] chore(deps): bump livetemplate to v0.8.18 for Session 3 patterns v0.8.18 contains the Session.TriggerAction fix (livetemplate#336) required by Session 3 patterns #14-16 (Lazy Loading, Progress Bar, Async Operations). Previously the library declared the Session interface but never wired ctx.WithSession() into any production code path, so ctx.Session() returned nil and goroutine-based server push silently no-op'd. v0.8.18 wires Session at all five NewContext call sites (WS lifecycle, WS action, HTTP lifecycle, HTTP action, PubSub server-action dispatch) plus the previously-missing handleDispatchedAction and upload completion paths. This bump also activates the regression test for the login example's sendWelcomeMessage server push (added in 4f45ebe), which was previously broken in the same way. Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 28 +--------------------------- go.sum | 55 ++----------------------------------------------------- 2 files changed, 3 insertions(+), 80 deletions(-) diff --git a/go.mod b/go.mod index 7a2a716..078a795 100644 --- a/go.mod +++ b/go.mod @@ -7,28 +7,17 @@ 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.8.17 + github.com/livetemplate/livetemplate v0.8.18 github.com/livetemplate/lvt v0.1.4-0.20260410132914-2f543135f074 github.com/livetemplate/lvt/components v0.1.2 modernc.org/sqlite v1.43.0 ) require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/brianvoe/gofakeit/v7 v7.8.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -39,35 +28,20 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mfridman/interpolate v0.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/pressly/goose/v3 v3.26.0 // indirect github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/sethvargo/go-retry v0.3.0 // indirect github.com/tdewolff/minify/v2 v2.24.8 // indirect github.com/tdewolff/parse/v2 v2.8.5 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect - golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.67.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 46307e8..5d29d91 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,6 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/brianvoe/gofakeit/v7 v7.8.2 h1:FWxoSP4Ss9LWSvTOrWZHz7sIHcpZwLVw2xa/DhJABB4= -github.com/brianvoe/gofakeit/v7 v7.8.2/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -18,20 +12,6 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= @@ -65,8 +45,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= @@ -114,30 +92,20 @@ 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.8.17 h1:VE7KLD13hqUo0wVAej3DFcgQkS0FdVZcQHGzXF8h33k= -github.com/livetemplate/livetemplate v0.8.17/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= -github.com/livetemplate/lvt v0.1.3 h1:owC7dKCnGoJBuUgeEFDmwqY7YcL3elgxnh7euNZBh5o= -github.com/livetemplate/lvt v0.1.3/go.mod h1:17cFl500ntymD3gx8h+ZODnVnTictHgG8Wmz/By75sU= +github.com/livetemplate/livetemplate v0.8.18 h1:UN74z9DinjV8I38NxUnLTfcAKMplI9XfuOAi4ApqbG0= +github.com/livetemplate/livetemplate v0.8.18/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= github.com/livetemplate/lvt v0.1.4-0.20260410132914-2f543135f074 h1:0YYh5Uc0bTTc6f8A66G91Fm213UwaexO81O7meTjscE= github.com/livetemplate/lvt v0.1.4-0.20260410132914-2f543135f074/go.mod h1:17cFl500ntymD3gx8h+ZODnVnTictHgG8Wmz/By75sU= github.com/livetemplate/lvt/components v0.1.2 h1:MM2M5IZnsUAu0py9ZbtcQCo0bvUrL4Z3Ly/yDkYNyag= github.com/livetemplate/lvt/components v0.1.2/go.mod h1:G9PElN3LRf8xoRtoxbOAcTkV/4FhrCE/Laczkz5bfL4= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= -github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= -github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= @@ -158,12 +126,6 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -178,17 +140,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= -github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= -github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -211,8 +166,6 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -225,8 +178,6 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= @@ -237,7 +188,6 @@ golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -247,7 +197,6 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= From fc005b66bbf7bef5fbb7170cc6620a33fcf02df7 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Tue, 14 Apr 2026 23:26:26 +0530 Subject: [PATCH 03/17] fix: address PR #70 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six items from Copilot review on the Session 3 patterns PR: 1. LazyLoadController.Reload — check session before mutating state (handlers_loading.go) Copilot: Reload set state.Loading=true and state.Data="" first, then early-returned if ctx.Session() was nil — leaving the page in a permanent loading spinner with no recovery path. Reordered so the session check happens first; if nil, return without touching state. With livetemplate v0.8.18+ the nil branch is unreachable (every action context now has WithSession wired), but the safer ordering means a future framework regression surfaces as "Reload does nothing" rather than "spinner stuck forever". 2. LazyLoadController.OnConnect — clarify nil-session intent (handlers_loading.go) Copilot raised the same "stuck loading forever" concern for OnConnect, but the existing structure was already correct (session check before goroutine spawn). Updated the doc comment to explicitly call out (a) why the defensive check stays even though v0.8.18+ guarantees non-nil session, and (b) that the "spinner-forever-with-JS-disabled" case is the documented fallback behaviour for the Lazy Loading pattern, not a bug. 3. ProgressBarController.Start — check session before Running=true (handlers_loading.go) Copilot: Start set Running=true before checking ctx.Session(). If session were nil the goroutine wouldn't spawn, but Running would stay true forever and the Running guard at the top of Start would block all subsequent button clicks. Reordered to check session first. 4. AsyncOpsController.Fetch — check session before Status="loading" (handlers_loading.go) Same pattern as #3. The button is template-disabled when Status=="loading", so leaving Status pinned to "loading" with no goroutine to clear it would freeze the UI. Reordered. 5. math/rand "non-deterministic by design" comment — clarify (handlers_loading.go) Copilot incorrectly claimed math/rand top-level functions are deterministic unless seeded. They were until Go 1.19, but Go 1.20 changed math/rand to auto-seed at program startup from a system source. Updated the comment to explicitly cite the Go 1.20 behaviour change so the reasoning is clear and the comment doesn't read as wishful thinking. 6. TestAsyncOperations flash assertion strength (patterns_test.go) Copilot: the test only checked for presence of the output[data-flash="success"|"error"] element. An empty placeholder element with no text would satisfy the selector and silently mask a regression where SetFlash wasn't called. Strengthened the assertion to read element.textContent and verify it contains the expected literal flash text ("Fetch complete" or "Fetch failed"), matching what the controller's FetchResult method passes to ctx.SetFlash. Items explicitly NOT changed: - Copilot's suggestion to add synchronous data-load fallbacks for non-WebSocket clients in Lazy Loading. The proposal explicitly documents the JS-disabled limitation as expected behaviour for pattern #14 — adding a synchronous fallback would defeat the pattern's purpose of demonstrating server push, and the spinner- forever case is preferable to silently rendering empty content. - Copilot's suggestion to add "live session unavailable" error flashes for Progress Bar / Async Ops. With v0.8.18+ the case is unreachable, so the speculative recovery UI would just be dead code that confuses future readers. All Session 3 tests still pass against the published v0.8.18. Co-Authored-By: Claude Opus 4.6 (1M context) --- patterns/handlers_loading.go | 46 ++++++++++++++++++++++++++++-------- patterns/patterns_test.go | 26 ++++++++++++++++---- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/patterns/handlers_loading.go b/patterns/handlers_loading.go index ea2030b..195a16e 100644 --- a/patterns/handlers_loading.go +++ b/patterns/handlers_loading.go @@ -31,9 +31,19 @@ func (c *LazyLoadController) Mount(state LazyLoadState, ctx *livetemplate.Contex } func (c *LazyLoadController) OnConnect(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { + // Skip if the data has already arrived (e.g., reconnect after a network + // hiccup) — re-spawning the goroutine would emit a duplicate update. if !state.Loading { return state, nil } + // Session is guaranteed non-nil by livetemplate v0.8.18+ (every connect + // path wires WithSession), but the defensive check stays so a future + // regression that re-introduces nil sessions surfaces as "no push happens" + // rather than a panic. Note: if session is nil here, state.Loading + // remains true and the spinner is shown indefinitely. This is the + // documented JS-disabled fallback behaviour for the Lazy Loading + // pattern (see proposal §14) — JS-enabled clients always reach the + // WebSocket connect path that wires the session. session := ctx.Session() if session == nil { return state, nil @@ -54,12 +64,16 @@ func (c *LazyLoadController) DataLoaded(state LazyLoadState, ctx *livetemplate.C } func (c *LazyLoadController) Reload(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { - state.Loading = true - state.Data = "" + // Check session BEFORE mutating state. With livetemplate v0.8.18+ this + // is always non-nil, but the early return ensures the UI does not + // transition into Loading=true with no goroutine to ever clear it + // — which would happen if the framework's session wiring regressed. session := ctx.Session() if session == nil { return state, nil } + state.Loading = true + state.Data = "" go func() { time.Sleep(lazyLoadDelay) _ = session.TriggerAction("dataLoaded", map[string]interface{}{ @@ -98,13 +112,18 @@ func (c *ProgressBarController) Start(state ProgressBarState, ctx *livetemplate. if state.Running { return state, nil } - state.Running = true - state.Done = false - state.Progress = 0 + // Check session BEFORE setting Running=true. With livetemplate v0.8.18+ + // this is always non-nil, but if it ever became nil the previous + // ordering (mutate first, check second) would leave Running=true + // permanently and the Running guard above would block all subsequent + // Start clicks. Checking first ensures the UI stays interactive. session := ctx.Session() if session == nil { return state, nil } + state.Running = true + state.Done = false + state.Progress = 0 go func() { for i := progressStep; i <= 100; i += progressStep { time.Sleep(progressTickRate) @@ -151,17 +170,24 @@ type AsyncOpsController struct{} const asyncFetchDelay = 2 * time.Second func (c *AsyncOpsController) Fetch(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) { - state.Status = "loading" - state.Result = "" - state.Error = "" + // Check session BEFORE setting Status="loading". With livetemplate + // v0.8.18+ this is always non-nil, but if it ever became nil the + // previous ordering (mutate first, check second) would leave the + // button stuck showing "Fetching..." with no goroutine to clear it. session := ctx.Session() if session == nil { return state, nil } + state.Status = "loading" + state.Result = "" + state.Error = "" go func() { time.Sleep(asyncFetchDelay) - // Simulated ~33% failure rate. Non-deterministic by design — tests - // must assert {success OR error}, not a specific branch. + // Simulated ~33% failure rate. Non-deterministic between runs because + // Go 1.20+ auto-seeds top-level math/rand from a system source at + // program startup — no rand.Seed call is needed. Tests must assert + // {success OR error}, not a specific branch, since either may fire + // on any given run. if rand.Intn(3) == 0 { _ = session.TriggerAction("fetchResult", map[string]interface{}{ "success": false, diff --git a/patterns/patterns_test.go b/patterns/patterns_test.go index cdeb840..d2735d3 100644 --- a/patterns/patterns_test.go +++ b/patterns/patterns_test.go @@ -1632,7 +1632,11 @@ func TestAsyncOperations(t *testing.T) { if err != nil { t.Fatalf("Async flow did not complete: %v", err) } - // Exactly one of success or error must be present, plus the matching flash. + // Exactly one of success or error must be present, plus the matching + // flash. The flash text is asserted against the controller's exact + // SetFlash message, not just the element presence — an empty + // placeholder would satisfy a presence-only + // check and silently mask a regression where SetFlash wasn't called. var outcome string _ = chromedp.Run(ctx, chromedp.Evaluate(`(() => { if (document.querySelector('blockquote')) return 'success'; @@ -1642,10 +1646,22 @@ func TestAsyncOperations(t *testing.T) { if outcome == "none" { t.Fatal("No outcome (neither success nor error) rendered") } - var hasFlash bool - _ = chromedp.Run(ctx, chromedp.Evaluate(`!!document.querySelector('output[data-flash="`+outcome+`"]')`, &hasFlash)) - if !hasFlash { - t.Errorf("Outcome %q: expected matching flash output[data-flash=%q]", outcome, outcome) + // Map outcome → expected flash text from the controller. + // Mirrors AsyncOpsController.FetchResult ctx.SetFlash calls. + expectedFlashText := map[string]string{ + "success": "Fetch complete", + "error": "Fetch failed", + }[outcome] + var flashText string + _ = chromedp.Run(ctx, chromedp.Evaluate( + `(() => { const el = document.querySelector('output[data-flash="`+outcome+`"]'); return el ? el.textContent.trim() : ""; })()`, + &flashText, + )) + if flashText == "" { + t.Errorf("Outcome %q: expected output[data-flash=%q] to exist with non-empty text, got empty", outcome, outcome) + } + if !strings.Contains(flashText, expectedFlashText) { + t.Errorf("Outcome %q: flash text = %q, want it to contain %q", outcome, flashText, expectedFlashText) } }) From e32e703c64f1843d2d18c146812c1f18a45143c0 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Wed, 15 Apr 2026 08:04:15 +0530 Subject: [PATCH 04/17] fix: address PR #70 round-2 Claude review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven items from Claude Code Review on the rebased session-3 branch (now that the workflow fix from #71 is on main, Claude is finally posting reviews on examples PRs — this is the first one): 1. LazyLoad goroutines now use the documented cancellation pattern (handlers_loading.go) OnConnect and Reload previously ignored TriggerAction errors via `_ = session.TriggerAction(...)`. ProgressBarController.Start uses the documented `if err := session.TriggerAction(...); err != nil { return }` form to bail when the session disconnects mid-loop. Adopting the same form here makes the cancellation pattern consistent across the file and matches the proposal's Server Push pattern (#31). For the lazy-load case the goroutine is short enough that leaking it is harmless in practice, but consistency matters more. 2. Reconnect-during-loading note in OnConnect Claude observed that if the client reconnects within the 2s loading window, OnConnect fires again and a second goroutine spawns while the first is still asleep. With the err-returning pattern from #1, the first goroutine's TriggerAction returns an error (stale session) and exits cleanly — only the most recent goroutine completes. Added a comment explaining this implicit reliance on framework session-invalidation semantics. 3. Misleading comment on the OnConnect nil-session guard The previous comment claimed "the spinner-forever case is the documented JS-disabled fallback". This was wrong: JS-disabled clients never reach OnConnect at all (no WebSocket connection means OnConnect is never called). The actual JS-disabled fallback is created by Mount() returning Loading=true on the initial HTTP GET. The nil branch in OnConnect is purely a defensive guard against framework regressions — fixed the comment to say so. 4. TestLazyLoading/Reload_Refetches_Fresh_Content trivially-true inequality (patterns_test.go) The previous assertion `if firstContent == secondContent` was trivially false because the two strings have different prefixes ("Content loaded lazily at …" vs "Content reloaded at …") — they could never be equal regardless of timing. Replaced with positive assertions that firstContent contains the initial-load prefix and does NOT contain the reload prefix, and vice versa for secondContent. This actually proves both strings were generated by their respective controller paths (initial OnConnect vs Reload action), which was the original intent. 5. async-operations.tmpl: for error block (templates/loading/async-operations.tmpl, patterns_test.go) CLAUDE.md convention is `` for block-level error messages, while `` is reserved for highlighted/badge text. Switched the error detail block to for consistency with the rest of the patterns example. Updated TestAsyncOperations selectors at three sites (Initial_Load presence check, Fetch_Transitions wait condition, outcome detection switch) to match. 6. Login regression test: narrow `ins` selector to id (login/login_test.go, login/templates/auth.html) The previous test selector `WaitForText("ins", ...)` matched any on the page. If the login template ever gains another styled (e.g., a generic success flash), the test could match the wrong element and pass spuriously. Added an `id="server-welcome-message"` to the auth.html template's ServerMessage and switched the test to use the explicit id selector. The id is stable and unambiguous. 7. Reload implicit-guard comment Bundled into #1's comment block. ProgressBarController.Start has an explicit `if state.Running { return }` guard against double- click stacking. LazyLoadController.Reload doesn't need one because the template hides the Reload button while Loading=true. Added a short comment explaining the asymmetry so future readers don't wonder why the patterns differ. All Session 3 + login tests still pass against the published livetemplate v0.8.18. Co-Authored-By: Claude Opus 4.6 (1M context) --- login/login_test.go | 6 ++- login/templates/auth.html | 2 +- patterns/handlers_loading.go | 43 ++++++++++++++----- patterns/patterns_test.go | 31 +++++++++---- .../templates/loading/async-operations.tmpl | 2 +- 5 files changed, 61 insertions(+), 23 deletions(-) diff --git a/login/login_test.go b/login/login_test.go index 709d6d3..75d9ac5 100644 --- a/login/login_test.go +++ b/login/login_test.go @@ -205,8 +205,12 @@ func TestLoginE2E(t *testing.T) { // This message was pushed from the server at HH:MM:SS"}), which // ServerWelcome renders into the ServerMessage element. t.Run("Server Welcome Message via WebSocket Push", func(t *testing.T) { + // Use the explicit #server-welcome-message id rather than a bare + // `ins` selector so the test can't accidentally match a different + // element (e.g., a future generic success flash) and pass + // spuriously. The id lives on auth.html line 23. err := chromedp.Run(ctx, - e2etest.WaitForText(`ins`, "pushed from the server", 5*time.Second), + e2etest.WaitForText(`#server-welcome-message`, "pushed from the server", 5*time.Second), ) if err != nil { var body string diff --git a/login/templates/auth.html b/login/templates/auth.html index f0f5006..72ae59c 100644 --- a/login/templates/auth.html +++ b/login/templates/auth.html @@ -20,7 +20,7 @@

Dashboard

{{if .ServerMessage}} - {{.ServerMessage}} + {{.ServerMessage}} {{end}}

Welcome, {{.Username}}!

diff --git a/patterns/handlers_loading.go b/patterns/handlers_loading.go index 195a16e..944dfce 100644 --- a/patterns/handlers_loading.go +++ b/patterns/handlers_loading.go @@ -37,22 +37,33 @@ func (c *LazyLoadController) OnConnect(state LazyLoadState, ctx *livetemplate.Co return state, nil } // Session is guaranteed non-nil by livetemplate v0.8.18+ (every connect - // path wires WithSession), but the defensive check stays so a future - // regression that re-introduces nil sessions surfaces as "no push happens" - // rather than a panic. Note: if session is nil here, state.Loading - // remains true and the spinner is shown indefinitely. This is the - // documented JS-disabled fallback behaviour for the Lazy Loading - // pattern (see proposal §14) — JS-enabled clients always reach the - // WebSocket connect path that wires the session. + // path wires WithSession). The defensive check stays so a future + // framework regression surfaces as "no push happens" rather than a + // panic — but it should NOT be confused with the JS-disabled fallback. + // JS-disabled clients never reach OnConnect at all (no WebSocket = no + // OnConnect call); the JS-disabled spinner-forever case is created by + // Mount() returning Loading=true on the initial HTTP GET. The nil + // branch here is purely a defensive guard against framework bugs. session := ctx.Session() if session == nil { return state, nil } + // Reconnect-during-loading note: if the client disconnects and + // reconnects within the 2s window, OnConnect fires again and spawns + // a second goroutine while the first is still asleep. The first + // goroutine's TriggerAction will return an error when its session + // becomes invalid, so it exits cleanly via the cancellation pattern + // below — only the most recent goroutine completes successfully. + // This relies on framework session-invalidation semantics rather + // than an explicit guard, matching the documented Server Push + // pattern (proposal #31). go func() { time.Sleep(lazyLoadDelay) - _ = session.TriggerAction("dataLoaded", map[string]interface{}{ + if err := session.TriggerAction("dataLoaded", map[string]interface{}{ "data": "Content loaded lazily at " + time.Now().Format("15:04:05"), - }) + }); err != nil { + return // Session disconnected — stop cleanly. + } }() return state, nil } @@ -72,13 +83,23 @@ func (c *LazyLoadController) Reload(state LazyLoadState, ctx *livetemplate.Conte if session == nil { return state, nil } + // No explicit Running-style guard here (unlike ProgressBarController.Start) + // because the template hides the Reload button while Loading=true. The + // only way to re-trigger Reload during the 2s window is via a direct + // WebSocket message bypassing the rendered UI, which is intentional in + // the demo (a power user could send action: reload twice rapidly). If + // that happens, the first goroutine's TriggerAction returns an error + // when DataLoaded clears Loading and a second Reload starts; the err + // check below handles it. state.Loading = true state.Data = "" go func() { time.Sleep(lazyLoadDelay) - _ = session.TriggerAction("dataLoaded", map[string]interface{}{ + if err := session.TriggerAction("dataLoaded", map[string]interface{}{ "data": "Content reloaded at " + time.Now().Format("15:04:05"), - }) + }); err != nil { + return // Session disconnected — stop cleanly. + } }() return state, nil } diff --git a/patterns/patterns_test.go b/patterns/patterns_test.go index d2735d3..ad74707 100644 --- a/patterns/patterns_test.go +++ b/patterns/patterns_test.go @@ -1506,8 +1506,15 @@ func TestLazyLoading(t *testing.T) { }) t.Run("Reload_Refetches_Fresh_Content", func(t *testing.T) { - // Click Reload; spinner reappears; new content arrives with a - // different timestamp (proves a second goroutine ran, not a cached value). + // Click Reload; spinner reappears; new content arrives via a fresh + // goroutine push. The two strings have different prefixes ("Content + // loaded lazily at …" vs "Content reloaded at …"), so an inequality + // check between them is trivially true and would not actually prove + // that a second goroutine ran. Instead, assert directly on the + // expected prefix transitions: firstContent must be the + // initial-load message, secondContent must be the reload message. + // Both prefixes are produced by separate goroutine paths, so this + // assertion proves real second-goroutine execution. var firstContent, secondContent string err := chromedp.Run(ctx, chromedp.Text(`blockquote`, &firstContent, chromedp.ByQuery), @@ -1519,11 +1526,17 @@ func TestLazyLoading(t *testing.T) { if err != nil { t.Fatalf("Reload failed: %v", err) } + if !strings.Contains(firstContent, "Content loaded lazily") { + t.Errorf("First content was not the initial load message: %q", firstContent) + } + if strings.Contains(firstContent, "Content reloaded") { + t.Errorf("First content already had the reload prefix — test ordering broken: %q", firstContent) + } if !strings.Contains(secondContent, "Content reloaded") { - t.Errorf("Reload did not produce fresh content: %q", secondContent) + t.Errorf("Second content did not have the reload prefix: %q", secondContent) } - if firstContent == secondContent { - t.Errorf("Reload returned identical content (expected different timestamp): %q", secondContent) + if strings.Contains(secondContent, "Content loaded lazily") { + t.Errorf("Second content still had the initial-load prefix: %q", secondContent) } }) @@ -1607,7 +1620,7 @@ func TestAsyncOperations(t *testing.T) { e2etest.WaitForWebSocketReady(5*time.Second), e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 3*time.Second), e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.Evaluate(`!!document.querySelector('blockquote') || !!document.querySelector('mark')`, &hasResult), + chromedp.Evaluate(`!!document.querySelector('blockquote') || !!document.querySelector('del')`, &hasResult), ) if err != nil { t.Fatalf("Initial load failed: %v", err) @@ -1624,8 +1637,8 @@ func TestAsyncOperations(t *testing.T) { chromedp.Click(`button[name="fetch"]`, chromedp.ByQuery), // Loading state: button shows "Fetching..." and aria-busy. e2etest.WaitForText(`button[name="fetch"]`, "Fetching...", 3*time.Second), - // Final state: either
(success) or (error). - e2etest.WaitFor(`!!document.querySelector('blockquote') || !!document.querySelector('mark')`, 5*time.Second), + // Final state: either
(success) or (error). + e2etest.WaitFor(`!!document.querySelector('blockquote') || !!document.querySelector('del')`, 5*time.Second), // Button must re-enable (exits "loading" status). e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 3*time.Second), ) @@ -1640,7 +1653,7 @@ func TestAsyncOperations(t *testing.T) { var outcome string _ = chromedp.Run(ctx, chromedp.Evaluate(`(() => { if (document.querySelector('blockquote')) return 'success'; - if (document.querySelector('mark')) return 'error'; + if (document.querySelector('del')) return 'error'; return 'none'; })()`, &outcome)) if outcome == "none" { diff --git a/patterns/templates/loading/async-operations.tmpl b/patterns/templates/loading/async-operations.tmpl index 63f2fd9..0a033fa 100644 --- a/patterns/templates/loading/async-operations.tmpl +++ b/patterns/templates/loading/async-operations.tmpl @@ -11,6 +11,6 @@ {{.lvt.FlashTag "success"}} {{.lvt.FlashTag "error"}} {{with .Result}}
{{.}}
{{end}} - {{with .Error}}

{{.}}

{{end}} + {{with .Error}}{{.}}{{end}}
{{end}} From afb0f494b8ce3966359fa030bb0e943244d9be08 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Wed, 15 Apr 2026 08:08:16 +0530 Subject: [PATCH 05/17] revert: keep for async-operations error detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (e32e703) switched the error block in async-operations.tmpl from to {{.}} based on Claude's review nit flagging the deviation from CLAUDE.md convention. Reverting that specific change after follow-up discussion: - is shorter and reads cleaner in the template - is the more semantic choice for "highlighted text" which matches the error-detail role: the FlashTag above is the primary error indicator, and draws attention to the specific error string as a secondary highlight - Pico's default yellow background for works fine as a visual cue without needing block-level error styling - If a more error-specific look is wanted later, livetemplate.css can override mark[role="error"] or similar — keeping the markup tight today does not preclude a stronger style tomorrow The CLAUDE.md guidance (" for error messages") is preserved unchanged for now since this is one example's deliberate deviation, not a project-wide convention shift. If the team decides to allow for inline error highlights more broadly, that's a separate docs change. Test selectors at three sites in TestAsyncOperations are reverted back to `mark` to match the template. Co-Authored-By: Claude Opus 4.6 (1M context) --- patterns/patterns_test.go | 8 ++++---- patterns/templates/loading/async-operations.tmpl | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/patterns/patterns_test.go b/patterns/patterns_test.go index ad74707..522350e 100644 --- a/patterns/patterns_test.go +++ b/patterns/patterns_test.go @@ -1620,7 +1620,7 @@ func TestAsyncOperations(t *testing.T) { e2etest.WaitForWebSocketReady(5*time.Second), e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 3*time.Second), e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.Evaluate(`!!document.querySelector('blockquote') || !!document.querySelector('del')`, &hasResult), + chromedp.Evaluate(`!!document.querySelector('blockquote') || !!document.querySelector('mark')`, &hasResult), ) if err != nil { t.Fatalf("Initial load failed: %v", err) @@ -1637,8 +1637,8 @@ func TestAsyncOperations(t *testing.T) { chromedp.Click(`button[name="fetch"]`, chromedp.ByQuery), // Loading state: button shows "Fetching..." and aria-busy. e2etest.WaitForText(`button[name="fetch"]`, "Fetching...", 3*time.Second), - // Final state: either
(success) or (error). - e2etest.WaitFor(`!!document.querySelector('blockquote') || !!document.querySelector('del')`, 5*time.Second), + // Final state: either
(success) or (error). + e2etest.WaitFor(`!!document.querySelector('blockquote') || !!document.querySelector('mark')`, 5*time.Second), // Button must re-enable (exits "loading" status). e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 3*time.Second), ) @@ -1653,7 +1653,7 @@ func TestAsyncOperations(t *testing.T) { var outcome string _ = chromedp.Run(ctx, chromedp.Evaluate(`(() => { if (document.querySelector('blockquote')) return 'success'; - if (document.querySelector('del')) return 'error'; + if (document.querySelector('mark')) return 'error'; return 'none'; })()`, &outcome)) if outcome == "none" { diff --git a/patterns/templates/loading/async-operations.tmpl b/patterns/templates/loading/async-operations.tmpl index 0a033fa..63f2fd9 100644 --- a/patterns/templates/loading/async-operations.tmpl +++ b/patterns/templates/loading/async-operations.tmpl @@ -11,6 +11,6 @@ {{.lvt.FlashTag "success"}} {{.lvt.FlashTag "error"}} {{with .Result}}
{{.}}
{{end}} - {{with .Error}}{{.}}{{end}} + {{with .Error}}

{{.}}

{{end}} {{end}} From 5e78cb57fdea490972eb2466f5a3b429242723bb Mon Sep 17 00:00:00 2001 From: Adnaan Date: Wed, 15 Apr 2026 08:24:18 +0530 Subject: [PATCH 06/17] fix: address PR #70 round-3 Claude review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six items from Claude's two reviews on the recent commits: 1. AsyncOpsController.Fetch missing concurrent-call guard (handlers_loading.go) Real bug: ProgressBarController.Start guards against re-entry via `if state.Running { return }`, but AsyncOpsController.Fetch had no equivalent. The button is template-disabled during loading, but a direct WebSocket message bypassing the rendered UI could spawn two parallel goroutines, each calling TriggerAction("fetchResult") and producing duplicate state transitions and SetFlash calls. Added `if state.Status == "loading" { return state, nil }` at the top of Fetch, mirroring ProgressBarController's guard. 2. AsyncOpsController goroutine err-return for consistency (handlers_loading.go) The goroutine used `_ = session.TriggerAction(...)` on both branches while LazyLoadController and ProgressBarController use the `if err := session.TriggerAction(...); err != nil { return }` form. This is harmless for AsyncOps because the goroutine is single-shot (one TriggerAction call then exit), but inconsistent with the established pattern. Switched both branches to the err-return form so readers learning from this example see the idiomatic shape everywhere. Added a comment explaining that the err-return is defense-in-depth for a single-shot goroutine. 3. TestProgressBar/Start_Runs_To_Completion intermediate-tick check (patterns_test.go) The previous WaitFor only asserted `progress.value > 0`, which would also be satisfied by a regression where the goroutine instantly jumps to 100 without intermediate ticks. Strengthened to `progress.value > 0 && progress.value < 100`, matching the Run_Again_Restarts_Timer subtest. This proves the goroutine is actually ticking in 10% increments, not jumping straight to done. 4. progress-bar.tmpl: 100% complete label in Done state (templates/loading/progress-bar.tmpl) During Running, the template shows the progress bar plus a `

{{.Progress}}% complete

` label. When the job finishes and transitions to Done, the label disappeared, creating a small visual discontinuity (label visible at 10–90%, then vanishes at 100). Added `

100% complete

` to the Done branch for visual continuity. 5. aria-live on the error mark for screen reader announcement (templates/loading/async-operations.tmpl) Wrapped the error in `

` so screen readers announce the error when the page transitions from loading to error state. The success branch's

doesn't need aria-live because the FlashTag above already has appropriate ARIA semantics (output[role="status"] from the FlashTag helper). The error path's FlashTag uses role="alert" but the additional error detail in the mark needed its own announcement signal. 6. Concurrent Fetch regression test (patterns_test.go) Added TestAsyncOperations/Concurrent_Fetch_Reaches_Single_Result. Sends two `fetch` actions in immediate sequence via direct WebSocket message (bypassing the disabled button), waits for the cycle to complete, and asserts exactly one result element (blockquote OR mark) is present. This is a smoke test for the user-visible invariant — concurrent Fetches don't break the page — rather than a direct test of the guard logic, because detecting "the second call was rejected" from the rendered HTML is hard when the state machine is idempotent in its final state. Items NOT addressed: - Test description string "mark element" → already accurate after the previous revert (template uses , description says "mark"). - Reload button disable while Loading → the framework auto-adds aria-busy on form buttons during the WS round-trip, AND the Reload button only renders in the {{else}} branch when Loading=false, so a separate disabled attribute would be redundant. - TestLazyLoading no JS-disabled path test → not actionable, matches how all other patterns are structured. The JS-disabled spinner- forever case is documented in handlers_loading.go's OnConnect comment block. - async-operations swap → user explicitly settled this in the previous revert (keeping as the more semantic choice for "highlighted error detail" with potential CSS override later in livetemplate.css). - Reconnect goroutine comment polish → the existing comment block already explains the mechanics adequately. All Session 3 + login tests still pass against published v0.8.18. Co-Authored-By: Claude Opus 4.6 (1M context) --- patterns/handlers_loading.go | 27 ++++++++-- patterns/patterns_test.go | 53 ++++++++++++++++++- .../templates/loading/async-operations.tmpl | 2 +- patterns/templates/loading/progress-bar.tmpl | 1 + 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/patterns/handlers_loading.go b/patterns/handlers_loading.go index 944dfce..e6977f1 100644 --- a/patterns/handlers_loading.go +++ b/patterns/handlers_loading.go @@ -191,6 +191,15 @@ type AsyncOpsController struct{} const asyncFetchDelay = 2 * time.Second func (c *AsyncOpsController) Fetch(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) { + // Re-entrancy guard: block concurrent Fetch while one is already in + // flight. The button is template-disabled during loading, but a direct + // WebSocket message bypassing the rendered UI could otherwise spawn + // two parallel goroutines that both call TriggerAction("fetchResult"), + // producing two state transitions and two SetFlash calls on the same + // session. Mirrors the Running guard in ProgressBarController.Start. + if state.Status == "loading" { + return state, nil + } // Check session BEFORE setting Status="loading". With livetemplate // v0.8.18+ this is always non-nil, but if it ever became nil the // previous ordering (mutate first, check second) would leave the @@ -209,16 +218,26 @@ func (c *AsyncOpsController) Fetch(state AsyncOpsState, ctx *livetemplate.Contex // program startup — no rand.Seed call is needed. Tests must assert // {success OR error}, not a specific branch, since either may fire // on any given run. + // + // Both branches use the same `if err := …; err != nil { return }` + // pattern as the other controllers for consistency, even though + // this is a single-shot goroutine where there's nothing else to + // cancel — readers learning the pattern from this example should + // see the idiomatic form everywhere. if rand.Intn(3) == 0 { - _ = session.TriggerAction("fetchResult", map[string]interface{}{ + if err := session.TriggerAction("fetchResult", map[string]interface{}{ "success": false, "error": "Connection timed out", - }) + }); err != nil { + return // Session disconnected — stop cleanly. + } } else { - _ = session.TriggerAction("fetchResult", map[string]interface{}{ + if err := session.TriggerAction("fetchResult", map[string]interface{}{ "success": true, "result": "Data fetched successfully at " + time.Now().Format("15:04:05"), - }) + }); err != nil { + return // Session disconnected — stop cleanly. + } } }() return state, nil diff --git a/patterns/patterns_test.go b/patterns/patterns_test.go index 522350e..0f212d9 100644 --- a/patterns/patterns_test.go +++ b/patterns/patterns_test.go @@ -1573,11 +1573,16 @@ func TestProgressBar(t *testing.T) { t.Run("Start_Runs_To_Completion", func(t *testing.T) { // Click Start; progress element appears and ticks up. Goroutine runs // 10 × 500ms = 5s, so wait 10s for completion and the success flash. + // The intermediate-tick assertion (value > 0 AND value < 100) catches + // a regression where the goroutine skips intermediate ticks and jumps + // straight to 100 — a `value > 0` check alone would also be satisfied + // by an instant 100, missing the bug. err := chromedp.Run(ctx, chromedp.Click(`button[name="start"]`, chromedp.ByQuery), e2etest.WaitFor(`!!document.querySelector('progress')`, 3*time.Second), - // Progress element starts ticking above 0. - e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value > 0`, 3*time.Second), + // Progress element is mid-flight: above 0 and below 100. + // This proves the goroutine is actually ticking, not jumping. + e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value > 0 && document.querySelector('progress').value < 100`, 3*time.Second), // Run Again button indicates the Done state. e2etest.WaitForText(`button`, "Run Again", 10*time.Second), e2etest.WaitForText(`output[data-flash="success"]`, "Job complete", 3*time.Second), @@ -1678,5 +1683,49 @@ func TestAsyncOperations(t *testing.T) { } }) + // Regression test for the AsyncOpsController.Fetch Running guard. + // Without the guard, two rapid `fetch` actions sent via direct + // WebSocket message (bypassing the template-disabled button) would + // each spawn a goroutine that calls TriggerAction("fetchResult"), + // resulting in two state transitions, two SetFlash calls, and + // potentially malformed rendered state. With the guard, the second + // Fetch is a no-op (state.Status == "loading" → return early). + // + // This test asserts the user-visible invariant: concurrent Fetch + // calls leave the UI in a single consistent state with exactly one + // result element (blockquote OR mark, never both, never stacked). + // It does not directly verify the guard rejected the second call — + // detecting that from the rendered HTML is hard because the state + // machine is idempotent in its final state — but it does prove the + // guard's user-visible promise (concurrent Fetches don't break the + // page) holds. + t.Run("Concurrent_Fetch_Reaches_Single_Result", func(t *testing.T) { + var resultCount int + err := chromedp.Run(ctx, + // Wait for idle state from the previous subtest. + e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 3*time.Second), + // Send two Fetch actions in immediate sequence via direct WS, + // bypassing the rendered button (which would be disabled + // after the first click). + chromedp.Evaluate(`(() => { + window.liveTemplateClient.send({action: 'fetch'}); + window.liveTemplateClient.send({action: 'fetch'}); + })()`, nil), + // Wait for the cycle to complete: button returns to "Fetch Data". + // Total time: ~2s for the goroutine sleep + WS roundtrip. + e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 5*time.Second), + // Count result elements. Exactly one of (blockquote, mark) must + // be present. If two goroutines somehow corrupted the state + // machine, we might see zero, two of either, or both. + chromedp.Evaluate(`document.querySelectorAll('blockquote, mark').length`, &resultCount), + ) + if err != nil { + t.Fatalf("Concurrent Fetch test failed: %v", err) + } + if resultCount != 1 { + t.Errorf("Expected exactly 1 result element after concurrent Fetch, got %d", resultCount) + } + }) + runStandardSubtests(t, ctx, false, "Async Operations — 'Fetch Data' button followed by either a success flash and blockquote with fetch result, or an error flash and mark element with an error message") } diff --git a/patterns/templates/loading/async-operations.tmpl b/patterns/templates/loading/async-operations.tmpl index 63f2fd9..5489f6e 100644 --- a/patterns/templates/loading/async-operations.tmpl +++ b/patterns/templates/loading/async-operations.tmpl @@ -11,6 +11,6 @@ {{.lvt.FlashTag "success"}} {{.lvt.FlashTag "error"}} {{with .Result}}
{{.}}
{{end}} - {{with .Error}}

{{.}}

{{end}} + {{with .Error}}

{{.}}

{{end}} {{end}} diff --git a/patterns/templates/loading/progress-bar.tmpl b/patterns/templates/loading/progress-bar.tmpl index 400143d..f96771d 100644 --- a/patterns/templates/loading/progress-bar.tmpl +++ b/patterns/templates/loading/progress-bar.tmpl @@ -7,6 +7,7 @@

{{.Progress}}% complete

{{else if .Done}} +

100% complete

{{.lvt.FlashTag "success"}}
From 7bde32e154c99695f5122f3b188c3a9ddb33fc7c Mon Sep 17 00:00:00 2001 From: Adnaan Date: Wed, 15 Apr 2026 08:34:26 +0530 Subject: [PATCH 07/17] fix: address PR #70 round-4 Claude review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two items from Claude's latest review: 1. Reload double-goroutine comment was inaccurate (handlers_loading.go) Claude correctly caught that my comment claimed "TriggerAction returns an error when DataLoaded clears Loading and a second Reload starts; the err check below handles it" — but TriggerAction errors only on session disconnect, not on state changes. The actual behaviour is: both goroutines run to completion, both call TriggerAction successfully, and the second simply overwrites state.Data with a newer timestamp. This is harmless for the demo but the explanatory comment was wrong. Rewrote the comment to describe the actual mechanism (concurrent completion, harmless overwrite) and noted the migration path if stricter single-flight semantics are wanted later. 2. Document the deliberate deviation from CLAUDE.md (templates/loading/async-operations.tmpl) Claude has flagged the usage as a CLAUDE.md convention violation on three consecutive reviews. The user explicitly settled this in an earlier round: is the more semantic choice for "highlighted error detail" (the FlashTag above with role="alert" is the primary error indicator; this is a secondary highlight of the error string), and Pico's default styling works without needing block-level error markup. To stop the review- comment loop without changing the underlying decision, added an inline {{/* ... */}} template comment directly above the line explaining (a) it's a deliberate deviation, (b) the semantic reasoning, and (c) the override path via livetemplate.css if a stronger error look becomes desired. Future Claude reviews should see the comment and recognise it as a documented deviation rather than an oversight. Items NOT changed: - map[string]interface{} → map[string]any cosmetic suggestion: the livetemplate library defines the TriggerAction signature using interface{}, so callers naturally use the same syntax for type uniformity. Switching only the example would create a stylistic inconsistency with the framework's own API surface. All Session 3 tests still pass against published v0.8.18. Co-Authored-By: Claude Opus 4.6 (1M context) --- patterns/handlers_loading.go | 17 ++++++++++------- .../templates/loading/async-operations.tmpl | 6 ++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/patterns/handlers_loading.go b/patterns/handlers_loading.go index e6977f1..6cf3443 100644 --- a/patterns/handlers_loading.go +++ b/patterns/handlers_loading.go @@ -84,13 +84,16 @@ func (c *LazyLoadController) Reload(state LazyLoadState, ctx *livetemplate.Conte return state, nil } // No explicit Running-style guard here (unlike ProgressBarController.Start) - // because the template hides the Reload button while Loading=true. The - // only way to re-trigger Reload during the 2s window is via a direct - // WebSocket message bypassing the rendered UI, which is intentional in - // the demo (a power user could send action: reload twice rapidly). If - // that happens, the first goroutine's TriggerAction returns an error - // when DataLoaded clears Loading and a second Reload starts; the err - // check below handles it. + // because the template hides the Reload button while Loading=true, so a + // click cannot re-trigger Reload during the 2s window. A direct WebSocket + // message could bypass the rendered UI and call Reload again; if that + // happens, both goroutines run to completion and both call TriggerAction + // successfully (TriggerAction errors only on session disconnect, not on + // state changes). The second goroutine's payload simply overwrites + // state.Data with a newer timestamp, which is harmless — the user sees + // the most recent reload's content. If stricter single-flight semantics + // are wanted later, copy ProgressBarController.Start's + // `if state.Loading { return state, nil }` guard to the top of this method. state.Loading = true state.Data = "" go func() { diff --git a/patterns/templates/loading/async-operations.tmpl b/patterns/templates/loading/async-operations.tmpl index 5489f6e..7dd2fc0 100644 --- a/patterns/templates/loading/async-operations.tmpl +++ b/patterns/templates/loading/async-operations.tmpl @@ -11,6 +11,12 @@ {{.lvt.FlashTag "success"}} {{.lvt.FlashTag "error"}} {{with .Result}}
{{.}}
{{end}} + {{/* Deliberate deviation from CLAUDE.md's " for errors" guidance: + is the more semantic choice for "highlighted error detail" + (the FlashTag above is the primary error indicator with role="alert"; + this is a secondary highlight of the specific error string). The + template stays terse; if a stronger error look is wanted later, + override mark[aria-live] in livetemplate.css. */}} {{with .Error}}

{{.}}

{{end}} {{end}} From 81ae90704f92103920143aa493364264e7d26546 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Wed, 15 Apr 2026 08:49:56 +0530 Subject: [PATCH 08/17] fix: address PR #70 round-5 Claude review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document why ProgressBar/AsyncOps controllers don't need OnConnect: state has no `lvt:"persist"` tags, so reconnects always produce fresh state via cloneStateTyped() rather than restoring stuck Running=true / Status="loading". The "stuck after reconnect" scenario the bot flagged cannot occur in ephemeral mode. LazyLoadController needs OnConnect because the spinner→data swap *is* the pattern; ProgressBar/AsyncOps patterns are about the goroutine push itself, and a mid-run disconnect cleanly ends the demo. - map[string]interface{} → map[string]any throughout handlers_loading.go for consistency with linter expectations and bot review. Co-Authored-By: Claude Opus 4.6 (1M context) --- patterns/handlers_loading.go | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/patterns/handlers_loading.go b/patterns/handlers_loading.go index 6cf3443..865c4ac 100644 --- a/patterns/handlers_loading.go +++ b/patterns/handlers_loading.go @@ -59,7 +59,7 @@ func (c *LazyLoadController) OnConnect(state LazyLoadState, ctx *livetemplate.Co // pattern (proposal #31). go func() { time.Sleep(lazyLoadDelay) - if err := session.TriggerAction("dataLoaded", map[string]interface{}{ + if err := session.TriggerAction("dataLoaded", map[string]any{ "data": "Content loaded lazily at " + time.Now().Format("15:04:05"), }); err != nil { return // Session disconnected — stop cleanly. @@ -98,7 +98,7 @@ func (c *LazyLoadController) Reload(state LazyLoadState, ctx *livetemplate.Conte state.Data = "" go func() { time.Sleep(lazyLoadDelay) - if err := session.TriggerAction("dataLoaded", map[string]interface{}{ + if err := session.TriggerAction("dataLoaded", map[string]any{ "data": "Content reloaded at " + time.Now().Format("15:04:05"), }); err != nil { return // Session disconnected — stop cleanly. @@ -124,6 +124,18 @@ func lazyLoadingHandler(baseOpts []livetemplate.Option) http.Handler { // 10% to 100% in 10% increments every 500ms. The goroutine exits cleanly if // session.TriggerAction returns an error (session disconnected) — this is the // canonical cancellation pattern documented in the Server Push pattern (#31). +// +// Reconnect semantics — why no OnConnect: +// ProgressBarState has no `lvt:"persist"` struct tags, so `h.persistable == nil` +// in the framework and `restorePersistedState` returns (nil, false). On every +// WebSocket connect (including reconnects after a network blip), mount.go falls +// through to `cloneStateTyped()` and produces fresh zero-value state +// (Running=false, Done=false, Progress=0). The "stuck Running=true with no +// goroutine" scenario therefore cannot occur — reconnecting always shows the +// Start button again. LazyLoadController needs OnConnect because the spinner +// vs. data swap is the *whole point* of that pattern; here the pattern is the +// goroutine ticking the bar, and a mid-run disconnect simply ends that demo +// (the user clicks Start again on the next page load). type ProgressBarController struct{} const ( @@ -151,7 +163,7 @@ func (c *ProgressBarController) Start(state ProgressBarState, ctx *livetemplate. go func() { for i := progressStep; i <= 100; i += progressStep { time.Sleep(progressTickRate) - if err := session.TriggerAction("updateProgress", map[string]interface{}{ + if err := session.TriggerAction("updateProgress", map[string]any{ "progress": i, }); err != nil { return // Session disconnected — stop cleanly. @@ -189,6 +201,17 @@ func progressBarHandler(baseOpts []livetemplate.Option) http.Handler { // and pushes a "fetchResult" action with either a success payload or an error // payload. Demonstrates the minimal state-machine shape you'd use for any // async RPC (database query, HTTP API, job queue, etc.). +// +// Reconnect semantics — why no OnConnect (same reasoning as ProgressBarController): +// AsyncOpsState has no `lvt:"persist"` tags, so a reconnect mid-fetch produces +// fresh zero-value state (Status="") via cloneStateTyped, not a stuck +// Status="loading". The user always sees the Fetch Data button after a +// reconnect. The in-flight goroutine's eventual TriggerAction either lands on +// the new connection (showing a result the user didn't initiate — harmless, +// since this is a demo) or errors out cleanly when the goroutine's session +// is gone. Adding OnConnect to "recover" loading state would actively make +// this worse by trying to restore Status="loading" against a goroutine that +// the framework has already torn down. type AsyncOpsController struct{} const asyncFetchDelay = 2 * time.Second @@ -228,14 +251,14 @@ func (c *AsyncOpsController) Fetch(state AsyncOpsState, ctx *livetemplate.Contex // cancel — readers learning the pattern from this example should // see the idiomatic form everywhere. if rand.Intn(3) == 0 { - if err := session.TriggerAction("fetchResult", map[string]interface{}{ + if err := session.TriggerAction("fetchResult", map[string]any{ "success": false, "error": "Connection timed out", }); err != nil { return // Session disconnected — stop cleanly. } } else { - if err := session.TriggerAction("fetchResult", map[string]interface{}{ + if err := session.TriggerAction("fetchResult", map[string]any{ "success": true, "result": "Data fetched successfully at " + time.Now().Format("15:04:05"), }); err != nil { From fed2d352066d43c4946b8bdd44e1ff3bb39d30f8 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Wed, 15 Apr 2026 09:02:13 +0530 Subject: [PATCH 09/17] fix: address PR #70 round-6 Claude review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestProgressBar.Run_Again_Restarts_Timer now asserts the success flash re-appears on the second completion. Catches a regression where the controller forgot to call SetFlash on the re-completion path. - Rewrote LazyLoadController.OnConnect reconnect-during-loading comment to accurately describe both possible outcomes (gap → goroutine errors; reconnected → both goroutines dispatch to new connection, second overwrites Data harmlessly). The previous version claimed framework session-invalidation semantics that don't actually exist — sessions are looked up by groupID, and groupID is stable across reconnects. Co-Authored-By: Claude Opus 4.6 (1M context) --- patterns/handlers_loading.go | 22 +++++++++++++++------- patterns/patterns_test.go | 6 +++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/patterns/handlers_loading.go b/patterns/handlers_loading.go index 865c4ac..59ee1f8 100644 --- a/patterns/handlers_loading.go +++ b/patterns/handlers_loading.go @@ -50,13 +50,21 @@ func (c *LazyLoadController) OnConnect(state LazyLoadState, ctx *livetemplate.Co } // Reconnect-during-loading note: if the client disconnects and // reconnects within the 2s window, OnConnect fires again and spawns - // a second goroutine while the first is still asleep. The first - // goroutine's TriggerAction will return an error when its session - // becomes invalid, so it exits cleanly via the cancellation pattern - // below — only the most recent goroutine completes successfully. - // This relies on framework session-invalidation semantics rather - // than an explicit guard, matching the documented Server Push - // pattern (proposal #31). + // a second goroutine while the first is still asleep. Both goroutines + // dispatch via groupID lookup (registry.GetByGroup), and groupID is + // stable across reconnects (cookie-bound), so when each goroutine + // wakes one of two things happens: + // (a) The reconnect hasn't completed yet → GetByGroup returns no + // connections → TriggerAction returns "no connected sessions" + // → goroutine exits via the cancellation pattern below. + // (b) The reconnect has completed → both goroutines successfully + // dispatch to the new connection. DataLoaded runs twice with + // slightly different timestamps; the second call overwrites + // Data. This is harmless — the user just sees the timestamp + // update once. Loading=false is idempotent. + // No explicit dedup guard is needed for this demo. Production code + // that absolutely requires single-flight semantics should track the + // in-flight request ID in state and check it inside DataLoaded. go func() { time.Sleep(lazyLoadDelay) if err := session.TriggerAction("dataLoaded", map[string]any{ diff --git a/patterns/patterns_test.go b/patterns/patterns_test.go index 0f212d9..ea86c2c 100644 --- a/patterns/patterns_test.go +++ b/patterns/patterns_test.go @@ -1594,11 +1594,15 @@ func TestProgressBar(t *testing.T) { t.Run("Run_Again_Restarts_Timer", func(t *testing.T) { // The Run Again button starts the timer again. Progress must begin - // from below 100 and climb back to completion. + // from below 100, climb back to completion, AND re-emit the success + // flash. The flash assertion catches a regression where the second + // run completes silently (e.g., if the controller forgot to call + // SetFlash on the re-completion path). err := chromedp.Run(ctx, chromedp.Click(`button[name="start"]`, chromedp.ByQuery), e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value > 0 && document.querySelector('progress').value < 100`, 3*time.Second), e2etest.WaitForText(`button`, "Run Again", 10*time.Second), + e2etest.WaitForText(`output[data-flash="success"]`, "Job complete", 3*time.Second), ) if err != nil { t.Fatalf("Run Again failed: %v", err) From bfbd0fb3e1a26d5647b19e094862bee3a52d81f3 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Wed, 15 Apr 2026 09:08:55 +0530 Subject: [PATCH 10/17] fix: address PR #70 round-6 Claude review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Loading re-entrancy guard to LazyLoadController.Reload, symmetric with ProgressBarController.Start and AsyncOpsController.Fetch. The previous comment correctly noted that the template hides the button while loading, but a direct WS message could bypass that — the asymmetry with the other two controllers was a trap for readers pattern-matching from this file. - TestAsyncOperations.Fetch_Transitions_Through_Loading_To_Result now WaitFor's the flash element before reading its text, and properly reports the chromedp.Run error instead of swallowing it. Also switches the JS string to fmt.Sprintf for readability. Co-Authored-By: Claude Opus 4.6 (1M context) --- patterns/handlers_loading.go | 22 +++++++++++----------- patterns/patterns_test.go | 20 ++++++++++++++------ 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/patterns/handlers_loading.go b/patterns/handlers_loading.go index 59ee1f8..62d6637 100644 --- a/patterns/handlers_loading.go +++ b/patterns/handlers_loading.go @@ -83,6 +83,17 @@ func (c *LazyLoadController) DataLoaded(state LazyLoadState, ctx *livetemplate.C } func (c *LazyLoadController) Reload(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { + // Re-entrancy guard, symmetric with ProgressBarController.Start and + // AsyncOpsController.Fetch. The template hides the Reload button while + // Loading=true so a click cannot re-trigger this during the 2s window, + // but a direct WebSocket message bypassing the rendered UI could — + // without this guard, two goroutines would both write state.Data and + // the second timestamp would overwrite the first. Harmless for a demo, + // but the asymmetry would be a trap for readers pattern-matching from + // this file. + if state.Loading { + return state, nil + } // Check session BEFORE mutating state. With livetemplate v0.8.18+ this // is always non-nil, but the early return ensures the UI does not // transition into Loading=true with no goroutine to ever clear it @@ -91,17 +102,6 @@ func (c *LazyLoadController) Reload(state LazyLoadState, ctx *livetemplate.Conte if session == nil { return state, nil } - // No explicit Running-style guard here (unlike ProgressBarController.Start) - // because the template hides the Reload button while Loading=true, so a - // click cannot re-trigger Reload during the 2s window. A direct WebSocket - // message could bypass the rendered UI and call Reload again; if that - // happens, both goroutines run to completion and both call TriggerAction - // successfully (TriggerAction errors only on session disconnect, not on - // state changes). The second goroutine's payload simply overwrites - // state.Data with a newer timestamp, which is harmless — the user sees - // the most recent reload's content. If stricter single-flight semantics - // are wanted later, copy ProgressBarController.Start's - // `if state.Loading { return state, nil }` guard to the top of this method. state.Loading = true state.Data = "" go func() { diff --git a/patterns/patterns_test.go b/patterns/patterns_test.go index ea86c2c..5f9a335 100644 --- a/patterns/patterns_test.go +++ b/patterns/patterns_test.go @@ -1674,13 +1674,21 @@ func TestAsyncOperations(t *testing.T) { "success": "Fetch complete", "error": "Fetch failed", }[outcome] + // Wait for the matching flash element to be injected before reading + // its text. Without this WaitFor, a race between the result render + // and the flash render could read an empty string and produce a + // confusing "expected non-empty" failure that masks the real cause. + flashSelector := fmt.Sprintf(`output[data-flash="%s"]`, outcome) var flashText string - _ = chromedp.Run(ctx, chromedp.Evaluate( - `(() => { const el = document.querySelector('output[data-flash="`+outcome+`"]'); return el ? el.textContent.trim() : ""; })()`, - &flashText, - )) - if flashText == "" { - t.Errorf("Outcome %q: expected output[data-flash=%q] to exist with non-empty text, got empty", outcome, outcome) + err = chromedp.Run(ctx, + e2etest.WaitFor(fmt.Sprintf(`!!document.querySelector('output[data-flash="%s"]')`, outcome), 3*time.Second), + chromedp.Evaluate( + fmt.Sprintf(`(() => { const el = document.querySelector('output[data-flash="%s"]'); return el ? el.textContent.trim() : ""; })()`, outcome), + &flashText, + ), + ) + if err != nil { + t.Fatalf("Outcome %q: failed to read %s: %v", outcome, flashSelector, err) } if !strings.Contains(flashText, expectedFlashText) { t.Errorf("Outcome %q: flash text = %q, want it to contain %q", outcome, flashText, expectedFlashText) From 5cdc329a0c727611f3b7cbfa1f34c5ac8d517338 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Wed, 15 Apr 2026 09:14:33 +0530 Subject: [PATCH 11/17] fix: address PR #70 round-7 Claude review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add