Skip to content

feat: patterns example Session 3 (patterns #14-16) — loading & progress#70

Merged
adnaan merged 17 commits intomainfrom
session-3-loading-progress
Apr 15, 2026
Merged

feat: patterns example Session 3 (patterns #14-16) — loading & progress#70
adnaan merged 17 commits intomainfrom
session-3-loading-progress

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Apr 14, 2026

Summary

Implements Session 3 of the patterns example proposal (livetemplate#333) — three Loading & Progress patterns sourced from htmx/Phoenix LiveView:

All three patterns rely on server push from a background goroutine via session.TriggerAction, which is why this PR depends on:

Dependency: livetemplate v0.8.18

Bumped from v0.8.17 to v0.8.18 in 2d0cf89. v0.8.18 contains the Session.TriggerAction wiring fix (livetemplate#336) — before this fix, 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. This was discovered while implementing Session 3 — none of the three patterns in this PR worked against v0.8.17 even though the proposal was written assuming Session.TriggerAction worked.

The library PR also addresses several adjacent issues found during review: native Go numeric type coercion in GetIntOk / GetFloatOk (matters for TriggerAction payloads where int values would previously return 0), WithSession wiring in handleDispatchedAction and the upload completion path, removal of dead SessionAware / BroadcastAware interfaces, and several documentation fixes around session-group vs. user scoping.

Changes

New files:

  • patterns/state_loading.go — three state structs (LazyLoadState, ProgressBarState, AsyncOpsState) following the Title/Category-first convention from Sessions 1 & 2
  • patterns/handlers_loading.go — three controllers + handler factories. LazyLoadController uses OnConnect to spawn the goroutine; ProgressBarController and AsyncOpsController spawn from action handlers. All goroutines use the recommended cancellation pattern (if err := session.TriggerAction(...); err != nil { return }).
  • patterns/templates/loading/lazy-loading.tmpl, progress-bar.tmpl, async-operations.tmpl — Pico semantic markup using <progress>, <blockquote>, <mark>, and the FlashTag helper

Modified:

  • patterns/main.go — three new route registrations under /patterns/loading/
  • patterns/data.go — three Implemented:true flips in allPatterns() (the index template iterates allPatterns() data-driven, so no index.tmpl edits needed)
  • patterns/patterns_test.go — three new chromedp E2E tests (TestLazyLoading, TestProgressBar, TestAsyncOperations) including UI standards subtests, condition-based waits (no chromedp.Sleep), and Visual_Check stubs gated on LVT_VISUAL_CHECK=true
  • login/login_test.go — adds Server_Welcome_Message_via_WebSocket_Push regression 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 <ins> element with a 5s timeout.
  • go.mod / go.sum — bump livetemplate to v0.8.18; go mod tidy also cleaned up ~20 stale indirect dependencies (charmbracelet TUI, clipboard, gofakeit, etc.) that were no longer referenced by any example. Pure cleanup, no functional impact.

Implementation notes followed (from the proposal's accumulated guidance)

  • ✅ State structs lead with Title string + Category string (layout.tmpl reads both)
  • ✅ Controllers use the in-memory shared DB pattern where applicable; LazyLoadController and AsyncOpsController are stateless singletons; ProgressBarController is also stateless because per-session Running flag in state struct prevents stacking
  • ✅ Mount guards on ctx.Action() == "" for LazyLoadController so the Reload POST doesn't reset state
  • ✅ Tests use e2etest.WaitFor / WaitForText with real JS predicates, never chromedp.Sleep
  • ✅ FlashTag rendered for every category the controller emits (success + error in async-operations, success in progress-bar)
  • ✅ async-operations template uses {{with}} on .Result / .Error to drop redundant {{if eq .Status}} wrappers around 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. Saves 4 lines and removes a layer of duplicated state-machine logic from the template.
  • ✅ Per-pattern files: state_loading.go + handlers_loading.go (not a single monolithic file)

Test plan

  • Local Session 3 tests against v0.8.18 (GOWORK=off go test -v -race ./patterns -run 'TestLazyLoading|TestProgressBar|TestAsyncOperations') — all pass
  • Local login E2E with new welcome regression subtest (GOWORK=off go test -v -race ./login -run TestLoginE2E) — all pass
  • Cross-handler navigation smoke test (GOWORK=off go test -v -race ./patterns -run TestCrossHandlerNavigation) — all 8 subtests pass
  • Manual review: ran go run ./patterns locally, clicked through all three pages and verified the goroutine-push behaviour visually
  • CI: full examples test suite via ./test-all.sh

Proposal tracker

This PR completes Session 3 of the patterns example proposal. The tracker in livetemplate/docs/proposals/patterns.md will be updated to check off Session 3's items in a follow-up commit on the livetemplate repo's main branch after this PR merges.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings April 14, 2026 17:38
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Session 3 (“Loading & Progress”) to the patterns example set, demonstrating server-push UI updates from background goroutines using Session.TriggerAction (enabled by the livetemplate v0.8.18 upgrade).

Changes:

  • Add 3 new patterns under /patterns/loading/*: Lazy Loading, Progress Bar, Async Operations.
  • Introduce new controller/state implementations and templates for the patterns.
  • Add E2E coverage for the new patterns and a login regression subtest for server-pushed welcome messages; bump github.com/livetemplate/livetemplate to v0.8.18 and tidy deps.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
patterns/handlers_loading.go New controllers/handlers implementing the 3 Loading & Progress patterns via background goroutines + Session.TriggerAction.
patterns/state_loading.go New state structs for Lazy Loading / Progress Bar / Async Ops patterns.
patterns/templates/loading/lazy-loading.tmpl New Lazy Loading page template (spinner → blockquote + reload).
patterns/templates/loading/progress-bar.tmpl New Progress Bar page template (progress element + success flash + rerun).
patterns/templates/loading/async-operations.tmpl New Async Ops template (loading → success/error with flash + result rendering).
patterns/main.go Registers new /patterns/loading/* routes.
patterns/data.go Marks the 3 patterns as implemented in the patterns catalog.
patterns/patterns_test.go Adds 3 new chromedp E2E tests for Session 3 patterns.
login/login_test.go Adds regression subtest verifying server-pushed welcome message arrives via WebSocket.
go.mod / go.sum Bumps livetemplate to v0.8.18 and removes stale indirect deps via tidy.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

state.Loading = true
state.Data = ""
session := ctx.Session()
if session == nil {
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LazyLoadController.Reload sets Loading=true and then returns early when ctx.Session() is nil, leaving the page in a permanent loading state with no way to recover. Consider resetting Loading to false (and/or setting a flash) when no session is available, or providing a synchronous fallback for non-WS requests.

Suggested change
if session == nil {
if session == nil {
state.Loading = false

Copilot uses AI. Check for mistakes.
state.Result = ""
state.Error = ""
session := ctx.Session()
if session == nil {
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AsyncOpsController.Fetch sets Status="loading" and disables the button in the template, but if ctx.Session() is nil it returns without scheduling a result push. That leaves the UI permanently stuck in the loading state. Consider handling the nil-session case by immediately transitioning back to idle/error with a flash, or by running the async work synchronously when WS push isn’t available.

Suggested change
if session == nil {
if session == nil {
state.Status = "error"
state.Error = "Unable to start fetch: live session unavailable"
state.Result = ""
ctx.SetFlash("error", "Fetch failed")

Copilot uses AI. Check for mistakes.
Comment on lines +161 to +165
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 {
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says the failure branch is “Non-deterministic by design”, but math/rand’s top-level functions are deterministic unless seeded. If you want true randomness, seed once (e.g., rand.Seed(time.Now().UnixNano())) or use a per-controller rand.Rand seeded at startup; otherwise, update the comment to reflect deterministic behavior.

Copilot uses AI. Check for mistakes.
Comment thread patterns/patterns_test.go Outdated
Comment on lines +1645 to +1649
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)
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only checks for the presence of output[data-flash="success"|"error"], which doesn’t guarantee the flash was actually set (an empty placeholder element would satisfy the selector). To make the regression meaningful, assert on the expected flash text (e.g., "Fetch complete" or "Fetch failed") for the rendered outcome.

Copilot uses AI. Check for mistakes.
Comment thread patterns/handlers_loading.go Outdated
Comment on lines +101 to +107
state.Running = true
state.Done = false
state.Progress = 0
session := ctx.Session()
if session == nil {
return state, nil
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Start(), state.Running is set to true before checking ctx.Session(). If session is nil (e.g., no WS/intercept), the UI will enter a permanent Running state and subsequent clicks will be ignored by the Running guard. Consider obtaining the session first (or resetting Running/Done/Progress and setting an error flash) when session is nil.

Suggested change
state.Running = true
state.Done = false
state.Progress = 0
session := ctx.Session()
if session == nil {
return state, nil
}
session := ctx.Session()
if session == nil {
return state, nil
}
state.Running = true
state.Done = false
state.Progress = 0

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +40
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
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LazyLoadController.OnConnect returns with state.Loading still true when ctx.Session() is nil, which leaves the page stuck showing the loading UI forever. Consider either (1) falling back to synchronous data load for non-WS requests, or (2) setting Loading=false and showing an error/info flash indicating that this pattern requires an active WebSocket session.

Copilot uses AI. Check for mistakes.
adnaan added a commit that referenced this pull request Apr 14, 2026
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) <noreply@anthropic.com>
adnaan added a commit that referenced this pull request Apr 15, 2026
The Claude Code Review workflow on this repo had not been posting
review comments on any PR — the workflow ran successfully (job
returned exit 0) but no comment ever appeared. Investigation on PR
#70 found the root cause: the workflow used a plugin-based prompt
invocation (`/code-review:code-review` via the
`code-review@claude-code-plugins` plugin from the
`anthropics/claude-code` marketplace) that silently fails to post.
The plugin appears to either suppress its output entirely or use
an internal posting path that does not work with the GitHub App
token context the action provides.

The livetemplate library repo's claude-code-review.yml uses a
different approach that posts comments reliably: an inline prompt
that explicitly tells Claude to call `gh pr comment` via its Bash
tool, plus a `claude_args` allow-list that whitelists the relevant
gh CLI subcommands. With that wiring, Claude generates the review,
runs `gh pr comment` itself (using the GH App installation token
that the Claude Code Action injects into the runner's environment),
and the comment shows up on the PR within 5–8 minutes.

This change brings the examples repo's workflow into parity with the
library workflow:

- Drop `plugin_marketplaces`, `plugins`, and the slash-command prompt
- Add inline prompt with explicit "Use `gh pr comment`" instruction
- Add `claude_args` with allow-list for: gh issue view, gh search,
  gh issue list, gh pr comment, gh pr diff, gh pr view, gh pr list
- Add `allowed_bots: copilot-swe-agent` (matches library config)

Trigger types are intentionally preserved (the examples repo runs on
`opened, synchronize, ready_for_review, reopened` while the library
runs on `opened, synchronize` only) — no functional reason to align
those, just preserving prior behavior to keep the change minimal.

After this lands, the next push to PR #70 (or any future examples
PR) should produce a Claude review comment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
adnaan added a commit that referenced this pull request Apr 15, 2026
The Claude Code Review workflow on this repo had not been posting
review comments on any PR — the workflow ran successfully (job
returned exit 0) but no comment ever appeared. Investigation on
#70 found two contributing problems:

1. The workflow used a plugin-based prompt invocation
   (`/code-review:code-review` via the `code-review@claude-code-plugins`
   plugin from the `anthropics/claude-code` marketplace) that silently
   fails to post review comments. The plugin appears to either suppress
   its output entirely or use an internal posting path that is not
   wired up correctly with the GitHub App token context the action
   provides.

2. The Claude Code Action validates the workflow file against the
   default branch's copy as a security mitigation, so any in-PR fix
   to this workflow itself fails with a 401 token-exchange error
   ("Workflow validation failed. The workflow file must exist and
   have identical content to the version on the repository's default
   branch"). This means the fix has to land on main first via a
   dedicated PR (this one) before it takes effect on any other PR.

The livetemplate library repo's claude-code-review.yml uses a
different approach that posts comments reliably:

- Inline prompt that explicitly tells Claude to call `gh pr comment`
  via its Bash tool
- `claude_args` allow-list whitelisting the relevant gh CLI
  subcommands (gh issue view, gh search, gh issue list,
  gh pr comment, gh pr diff, gh pr view, gh pr list)
- `allowed_bots: copilot-swe-agent` to interact with Copilot
  comments on the same PR

With that wiring, Claude generates the review, runs `gh pr comment`
itself (using the GH App installation token that the Claude Code
Action injects into the runner's environment), and the comment shows
up on the PR within 5–8 minutes. This has been working on the
library repo continuously, producing 4 review comments across the
recent rounds of the Session.TriggerAction PR
(livetemplate/livetemplate#336).

Changes in this PR:

- Drop `plugin_marketplaces`, `plugins`, and the slash-command prompt
- Add inline prompt with explicit "Use `gh pr comment`" instruction
- Add `claude_args` with the gh CLI tool allow-list
- Add `allowed_bots: copilot-swe-agent`

Trigger types are preserved unchanged (`opened, synchronize,
ready_for_review, reopened`) — no functional reason to align them
with the library's narrower trigger set, and shrinking them would
miss some PR state transitions.

After this lands on main, future PRs (and any rebased existing PR)
will pick up the new workflow on their next push and produce real
Claude review comments instead of the silent-no-op behaviour.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
adnaan added a commit that referenced this pull request Apr 15, 2026
#71)

The Claude Code Review workflow on this repo had not been posting
review comments on any PR — the workflow ran successfully (job
returned exit 0) but no comment ever appeared. Investigation on
#70 found two contributing problems:

1. The workflow used a plugin-based prompt invocation
   (`/code-review:code-review` via the `code-review@claude-code-plugins`
   plugin from the `anthropics/claude-code` marketplace) that silently
   fails to post review comments. The plugin appears to either suppress
   its output entirely or use an internal posting path that is not
   wired up correctly with the GitHub App token context the action
   provides.

2. The Claude Code Action validates the workflow file against the
   default branch's copy as a security mitigation, so any in-PR fix
   to this workflow itself fails with a 401 token-exchange error
   ("Workflow validation failed. The workflow file must exist and
   have identical content to the version on the repository's default
   branch"). This means the fix has to land on main first via a
   dedicated PR (this one) before it takes effect on any other PR.

The livetemplate library repo's claude-code-review.yml uses a
different approach that posts comments reliably:

- Inline prompt that explicitly tells Claude to call `gh pr comment`
  via its Bash tool
- `claude_args` allow-list whitelisting the relevant gh CLI
  subcommands (gh issue view, gh search, gh issue list,
  gh pr comment, gh pr diff, gh pr view, gh pr list)
- `allowed_bots: copilot-swe-agent` to interact with Copilot
  comments on the same PR

With that wiring, Claude generates the review, runs `gh pr comment`
itself (using the GH App installation token that the Claude Code
Action injects into the runner's environment), and the comment shows
up on the PR within 5–8 minutes. This has been working on the
library repo continuously, producing 4 review comments across the
recent rounds of the Session.TriggerAction PR
(livetemplate/livetemplate#336).

Changes in this PR:

- Drop `plugin_marketplaces`, `plugins`, and the slash-command prompt
- Add inline prompt with explicit "Use `gh pr comment`" instruction
- Add `claude_args` with the gh CLI tool allow-list
- Add `allowed_bots: copilot-swe-agent`

Trigger types are preserved unchanged (`opened, synchronize,
ready_for_review, reopened`) — no functional reason to align them
with the library's narrower trigger set, and shrinking them would
miss some PR state transitions.

After this lands on main, future PRs (and any rebased existing PR)
will pick up the new workflow on their next push and produce real
Claude review comments instead of the silent-no-op behaviour.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
adnaan and others added 3 commits April 15, 2026 07:43
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 `<progress>`, `<blockquote>`, `<mark>`, 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 <ins> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@adnaan adnaan force-pushed the session-3-loading-progress branch from 1c3261f to fc005b6 Compare April 15, 2026 02:14
@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — Session 3: Loading & Progress (#14–16)

Overall this is a well-structured, convention-following PR. The three patterns are implemented cleanly and the test suite is solid. A few items worth addressing before merge:


Issues

1. Lazy Loading goroutines ignore session disconnection (inconsistent cancellation pattern)

LazyLoadController uses _ = session.TriggerAction(...) in both OnConnect and Reload, silently swallowing errors:

go func() {
    time.Sleep(lazyLoadDelay)
    _ = session.TriggerAction("dataLoaded", map[string]interface{}{ ... })
}()

ProgressBarController correctly uses the documented cancellation pattern:

if err := session.TriggerAction("updateProgress", ...); err != nil {
    return // Session disconnected — stop cleanly.
}

For the lazy load case the delay is fixed at 2s so leaking the goroutine is harmless in practice, but it's inconsistent with the pattern demonstrated elsewhere in this same file and with the proposal's documented best practice. Suggest adopting the if err != nil { return } form for consistency so readers don't learn two different styles from the same PR.


2. No reconnect guard on LazyLoadController.OnConnect can spawn a second goroutine during the loading window

The current guard:

if !state.Loading {
    return state, nil
}

…correctly skips reconnects after data arrives. But if the client disconnects and reconnects while still loading (within the 2s window), state.Loading is still true and a second goroutine is spawned. The first goroutine will likely fail with a session error (old session), so in practice only the new one lands — but this is subtle and relies on framework session invalidation semantics. ProgressBarController.Start solves the analogous stacking problem explicitly with the Running flag. A Reconnecting bool state field (set in OnConnect, cleared in DataLoaded) would make this explicit rather than implicit.


3. Misleading comment on the nil-session guard in OnConnect

// 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.

JS-disabled clients never reach OnConnect at all (no WebSocket connection), so a nil session inside OnConnect is a framework bug, not a JS-disabled scenario. The comment inverts the actual meaning. Suggest: "If session is nil here it indicates a framework regression (v0.8.18+ always wires session on connect); the spinner will remain indefinitely rather than panicking."


4. TestLazyLoading/Reload_Refetches_Fresh_Content: inequality assertion is trivially satisfied

if firstContent == secondContent {
    t.Errorf("Reload returned identical content (expected different timestamp): %q", secondContent)
}

firstContent always contains "Content loaded lazily at …" and secondContent always contains "Content reloaded at …" — they carry different prefixes regardless of timing, so this assertion can never fail. The intent (prove two different goroutine runs, not a cache) is good but the implementation doesn't actually achieve it. Consider extracting and comparing only the timestamp portion, or asserting that firstContent does not contain "reloaded" and secondContent does.


Minor observations

  • Template rendering naturally prevents double-click on Reload: after Reload fires, Loading=true hides the button, so the UI-level guard is implicit. Worth a short comment in handlers_loading.go to explain why no Running-style guard is needed (unlike ProgressBarController), since future readers will notice the asymmetry.

  • async-operations.tmpl {{with .Error}}<p><mark>{{.}}</mark></p>{{end}}: Using <mark> for errors is unconventional (Pico CSS renders it as a highlight/badge). The CLAUDE.md convention uses <del style="display:block;text-decoration:none"> for block-level error messages. The current markup works visually but deviates from the project convention.

  • Login regression test uses ins selector without scope: e2etest.WaitForText(\ins`, "pushed from the server", 5*time.Second)would match anyon the page. If the login template ever gains a secondelement (e.g., a success flash), the test could pass spuriously. A narrower selector (e.g.,#server-message ins` or the specific element ID) would be more robust.


What's working well

  • Session nil checks are defensive, consistently placed before state mutations, and the reasoning is clearly commented — this ordering avoids stuck states if the session guard ever triggers.
  • ProgressBarController.Start guard against double-click stacking is clean.
  • All three E2E tests use condition-based waits throughout with no chromedp.Sleep.
  • Flash assertions verify text content, not just element presence — catches the regression where SetFlash wasn't called.
  • Templates are CSP-compliant; no inline handlers, no inline scripts, no inline styles beyond the approved <ins>/<del> pattern.
  • go mod tidy cleanup of the charmbracelet/goose transitive deps is a clean win with no functional risk.
  • The login regression subtest is a meaningful catch: the existing "Successful Login" subtest would have passed even with the broken session wiring.

🤖 Generated with Claude Code

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: <mark> → <del> for error block
   (templates/loading/async-operations.tmpl, patterns_test.go)

   CLAUDE.md convention is `<del style="display:block;text-
   decoration:none">` for block-level error messages, while `<mark>`
   is reserved for highlighted/badge text. Switched the error detail
   block to <del> 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
   <ins> on the page. If the login template ever gains another
   styled <ins> (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 <ins> 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) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — PR #70: Patterns Session 3 (Loading & Progress, #14–16)

Overall this is a well-structured, thoughtful PR. The server-push pattern is implemented correctly, the dependency bump is well-justified, and the test suite follows the checklist in CLAUDE.md closely. A few issues worth addressing before merge:


Bug: Test description string names the wrong element

patterns/patterns_test.goTestAsyncOperations runStandardSubtests call:

runStandardSubtests(t, ctx, false, "Async Operations — ... or an error flash and mark element with an error message")

The template renders errors inside <del>, not <mark>. The description should read "del element" (or just "error message block"). Low severity, but if runStandardSubtests passes this string to a vision model or accessibility check it would report a mismatch.


Inconsistent goroutine cancellation pattern

LazyLoadController and ProgressBarController both use the documented cancellation idiom:

if err := session.TriggerAction(...); err != nil {
    return // Session disconnected — stop cleanly.
}

AsyncOpsController.Fetch discards the error with _ = on both branches:

_ = session.TriggerAction("fetchResult", map[string]interface{}{...})

This is technically fine here since the goroutine terminates immediately after either branch. But for consistency with the example's own stated pattern — and for copy-paste safety for readers learning from this code — both calls should use the if err := ...; err != nil { return } form. The PR description says "All goroutines use the recommended cancellation pattern" but AsyncOpsController does not.


Reconnect goroutine race in LazyLoad (acknowledged but under-documented for readers)

The PR comment is accurate:

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 exits when its session becomes invalid, so there's no permanent leak. But the example code is supposed to teach the pattern — a reader seeing two go func() fire in quick succession without an explicit cancel mechanism may be confused. Consider a one-liner in the inline comment pointing to the "why it's safe" reasoning more directly, e.g.:

// Safe: if a second goroutine is spawned on reconnect, the first exits
// when session.TriggerAction returns a non-nil error (session invalidated
// by the framework on disconnect). Only the latest goroutine completes.

The explanation is already there but spread across several comment blocks; condensing it would make the example clearer as teaching material.


Minor: progress-bar.tmpl — missing <p><small> during Done state

When Done=true, the template shows:

<progress value="100" max="100"></progress>
{{.lvt.FlashTag "success"}}

But during Running, it shows the progress bar and a <p><small>{{.Progress}}% complete</small></p> label. When the job finishes and transitions to Done, the 100% complete label disappears. This creates a small visual discontinuity — the label is present during every tick from 10%–90% but vanishes at completion. Consider either removing the label from the Running state, or including it (<p><small>100% complete</small></p>) in the Done state for visual continuity.


Positive observations

  • Session nil-check ordering: Checking session == nil before mutating state in all three controllers is a good defensive pattern, and the comments explaining why order matters are excellent.
  • Condition-based waits throughout: No chromedp.Sleep anywhere in the new tests. ✅
  • Tier 1 compliance: All three patterns use standard <form method="POST"> + <button name="..."> — no gratuitous lvt-* attributes. ✅
  • CSP compliance: No inline event handlers or <script> blocks. ✅
  • Login regression test: The Server_Welcome_Message_via_WebSocket_Push subtest is a valuable addition — it catches the exact silent failure mode that existed in v0.8.17. The targeted #server-welcome-message selector (instead of a bare ins) is the right call.
  • Dependency cleanup: The go mod tidy removing charmbracelet/TUI/gofakeit is pure win — 20 fewer indirect deps.
  • State struct comments: The AsyncOpsState comment documenting the state machine values ("" | "loading" | "success" | "error") is exactly the right thing to document.

Summary

Item Severity
Test description: "mark element" → "del element" Low (doc bug)
AsyncOpsController goroutine ignores TriggerAction errors Low (inconsistency, not a bug)
Reconnect goroutine comment could be clearer for readers Low (teaching clarity)
Progress label missing in Done state Low (UX polish)

All are low-severity. The core implementation is correct and the test coverage meets the CLAUDE.md checklist requirements.

The previous commit (e32e703) switched the error block in
async-operations.tmpl from <mark> to <del style="display:block;
text-decoration:none">{{.}}</del> based on Claude's review nit
flagging the deviation from CLAUDE.md convention. Reverting that
specific change after follow-up discussion:

- <mark> is shorter and reads cleaner in the template
- <mark> is the more semantic choice for "highlighted text" which
  matches the error-detail role: the FlashTag above is the primary
  error indicator, and <mark> draws attention to the specific error
  string as a secondary highlight
- Pico's default yellow background for <mark> 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 ("<del> 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
<mark> 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) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — Session 3: Loading & Progress Patterns (#14#16)

Overall this is a well-structured PR. The goroutine patterns are documented clearly, tests are solid, and the templates follow project conventions faithfully. A few issues worth addressing before merging.


Bug / Logic Issue

AsyncOpsController has no guard against concurrent Fetch calls.

ProgressBarController.Start correctly blocks re-entry while state.Running is true. AsyncOpsController.Fetch does not, so clicking Fetch twice quickly (or sending two WebSocket messages) spawns two concurrent goroutines — both will call TriggerAction("fetchResult", ...), resulting in two state transitions and potentially two SetFlash calls on the same session. The button is disabled during loading in the template, but a direct WS message can bypass this.

Simple fix (same pattern as ProgressBarController):

func (c *AsyncOpsController) Fetch(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) {
    if state.Status == "loading" {
        return state, nil
    }
    // ... rest of method
}

Correctness Observations

Goroutine leak window during reconnect in LazyLoadController.

The PR's comment acknowledges that reconnecting within the 2 s window spawns a second goroutine, relying on framework session-invalidation to kill the first. This is fine in practice with v0.8.18, but it creates a fragile dependency on session-invalidation timing. If the framework ever delivers the first goroutine's TriggerAction before invalidating the old session, DataLoaded is called twice. Worth a brief note in the comment that this is intentionally accepted risk, or adding an explicit Loading guard in OnConnect:

// After reconnect guard at top of OnConnect:
if !state.Loading {
    return state, nil
}

(This is already there — good. The second-goroutine risk is small and the PR acknowledges it, just flagging it.)

_ = session.TriggerAction(...) in AsyncOpsController goroutine.

Unlike the other controllers, this uses _ = instead of if err != nil { return }. Since the goroutine only makes one call and exits regardless, this is harmless — there's nothing to cancel. But it's inconsistent with the established pattern and could confuse a reader. A comment like // single-shot: error is unactionable, goroutine exits either way would make the intent explicit.


Test Coverage Gaps

TestAsyncOperations doesn't cover the double-click race (the bug described above). Adding a subtest that sends Fetch twice in quick succession and asserts only one result element appears would catch this regression.

TestProgressBar / Run_Again_Restarts_Timer could verify that progress resets to 0 before climbing. Currently it only waits for "Run Again" text to reappear, which means a stuck-at-100 state machine could still pass if the label is rendered.

// After clicking Run Again:
e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value < 100`, 3*time.Second),

TestLazyLoading doesn't test the JS-disabled / no-WebSocket path. Not a blocker since it matches how other pattern tests are structured, but worth a comment that the spinner-forever case (Loading=true with no OnConnect) is intentional UX for JS-disabled clients.


Minor Template Feedback

async-operations.tmpl<p><mark> for error state.

{{with .Error}}<p><mark>{{.}}</mark></p>{{end}}

<mark> is documented in CLAUDE.md as "highlighted/badge text." An error message isn't typically a highlight — consider <del style="display:block;text-decoration:none"> (the standardized error block pattern used elsewhere in the project) or at minimum add aria-live="assertive" so screen readers announce the error.

lazy-loading.tmpl — the Reload button has no loading-state guard in the template. While Mount guards against resetting state mid-POST, the Reload button stays visible after clicking until the spinner appears. A brief disabled state while Loading would be cleaner UX:

<button name="reload" class="secondary" {{if .Loading}}disabled{{end}}>Reload</button>

Positive Notes

  • slices.Clone(baseOpts) in each handler factory correctly avoids shared-slice mutation.
  • Session nil checks are consistently placed before state mutation — the ordering comment explaining why is valuable.
  • WaitFor/WaitForText throughout; no chromedp.Sleep.
  • The {{with}} pattern in async-operations.tmpl is cleaner than the {{if eq .Status}} wrapper it replaces.
  • The login regression subtest (Server_Welcome_Message_via_WebSocket_Push) is a good catch — asserting on the server-push payload rather than the static template literal is exactly the right approach.
  • go.mod cleanup is welcome; removing 20 stale indirect dependencies is pure improvement.
  • The #server-welcome-message id targeting in the login test is well-reasoned in the comment.

Summary

The critical issue is the missing concurrent-fetch guard in AsyncOpsController. Everything else is minor polish. The patterns themselves and the dependency on v0.8.18 are well-reasoned and documented.

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
   `<p><small>{{.Progress}}% complete</small></p>` 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 `<p><small>100% complete</small></p>` 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 <mark> in `<p aria-live="assertive">` so screen
   readers announce the error when the page transitions from loading
   to error state. The success branch's <blockquote> 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 <mark>, 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 <mark> → <del> swap → user explicitly settled this
  in the previous revert (keeping <mark> 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) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — Session 3: Loading & Progress (Patterns #14–16)

Overall: high quality, one convention violation to fix before merge.


What this PR does

Implements three server-push patterns using session.TriggerAction from background goroutines, requires the v0.8.18 library fix that actually wires WithSession into production code paths, and adds a regression guard to the login example that would have caught the original nil-session bug.


Issues

Template convention violation — async-operations.tmpl

<mark> is used to display the error state:

{{with .Error}}<p aria-live="assertive"><mark>{{.}}</mark></p>{{end}}

Per CLAUDE.md, <mark> is for highlighted/badge text; <del> is for error messages:

Use <ins> for success messages, <del> for error messages, using the standardized inline style style="display:block;text-decoration:none" when rendering them as block-level alerts

The error element should be:

{{with .Error}}<del style="display:block;text-decoration:none" aria-live="assertive">{{.}}</del>{{end}}

This is the only thing blocking merge from a conventions standpoint. Note the E2E test also asserts on mark selector — it would need updating to match del.

Reload double-goroutine comment is inaccurate

handlers_loading.go says the first goroutine's TriggerAction returns an error "when DataLoaded clears Loading" — but TriggerAction errors on session disconnect, not on state changes. Both goroutines will complete and both will call TriggerAction successfully. The second just overwrites .Data with a newer timestamp, which is harmless, but the stated mechanism is wrong.

Recommend either:

  • Adding a Running-style guard (consistent with ProgressBarController.Start), or
  • Correcting the comment to say: "both goroutines complete; the second overwrites .Data with a newer timestamp, which is harmless"

Strengths

Controller design

  • The nil-session check-before-mutation ordering (check session → mutate state → spawn goroutine) is correct and the comments explaining why the ordering matters are unusually clear.
  • The cancellation pattern (if err := session.TriggerAction(...); err != nil { return }) is applied consistently across all three controllers, including the single-shot goroutines in AsyncOpsController.
  • ProgressBarController.Start checking session before setting Running=true correctly prevents both double-goroutine stacking and permanent lock-out — this is the right pattern.

Templates

  • All three templates are CSP-compliant: no inline handlers, no <style> blocks, no inline <script>.
  • progress-bar.tmpl uses Pico's native <progress> element correctly.
  • async-operations.tmpl's {{with .Result}}/{{with .Error}} idiom is cleaner than {{if eq .Status "success"}} wrappers — good use of Go templates.
  • lazy-loading.tmpl uses aria-busy="true" on the loading paragraph, which is the right accessibility pattern.

Tests

  • No chromedp.Sleep anywhere — all waits use condition-based helpers (WaitFor, WaitForText).
  • Every controller method has at least one E2E subtest.
  • Reload_Refetches_Fresh_Content avoids the "inequality is trivially true" trap by asserting on distinct message prefixes rather than just comparing two strings.
  • The intermediate-tick check in Start_Runs_To_Completion (value > 0 AND value < 100) catches a goroutine that skips straight to 100 in one step — most tests omit this.
  • Concurrent_Fetch_Reaches_Single_Result correctly uses liveTemplateClient.send() for the protocol-level concurrent test (per CLAUDE.md), waits for idle state first, and asserts the right invariant (exactly one result element).
  • The login regression subtest uses #server-welcome-message rather than a bare ins selector, which avoids false positives from future flash elements.

go.mod cleanup

Removing the charmbracelet/bubbletea stack, gofakeit, goose, etc. as stale indirect dependencies is clean. No functional risk.


Nits (non-blocking)

  • map[string]interface{} in TriggerAction payloads can be replaced with map[string]any (Go 1.18+), but cosmetic only.
  • asyncFetchDelay and lazyLoadDelay are both 2 * time.Second — fine to keep separate since they may diverge.

Summary: Fix the <mark><del> convention violation in async-operations.tmpl (and update the matching E2E selector), correct the Reload double-goroutine comment; everything else is solid.

🤖 Generated with Claude Code

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 <mark> deviation from CLAUDE.md
   (templates/loading/async-operations.tmpl)

   Claude has flagged the <mark> usage as a CLAUDE.md convention
   violation on three consecutive reviews. The user explicitly
   settled this in an earlier round: <mark> 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) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — Session 3: Loading & Progress Patterns (#14–16)

Overall this is a solid PR. The goroutine push patterns are well-structured, the controller logic is correct, and the tests are high quality. A few issues worth addressing before merge.


Bug: ProgressBar and AsyncOps are stuck after a WebSocket reconnect

This is the main concern in the PR.

LazyLoadController handles reconnects correctly — OnConnect re-spawns the goroutine if state.Loading == true. But ProgressBarController and AsyncOpsController have no OnConnect method. If a client's WebSocket disconnects mid-run:

  • The background goroutine exits (TriggerAction returns an error on the dead session) — good.
  • A new WebSocket connection fires OnConnect, but neither controller has one, so no new goroutine starts.
  • The server state remains Running=true / Status="loading" with no goroutine to clear it.
  • The re-entrancy guards then block any user-initiated recovery: Start returns early because Running == true; Fetch returns early because Status == "loading".

The user is stuck until they do a full page refresh. For ProgressBarController the fix mirrors the lazy-load pattern:

func (c *ProgressBarController) OnConnect(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) {
    if !state.Running {
        return state, nil
    }
    session := ctx.Session()
    if session == nil {
        return state, nil
    }
    // Restart from current progress, not from 0.
    go func() {
        for i := state.Progress + progressStep; i <= 100; i += progressStep {
            time.Sleep(progressTickRate)
            if err := session.TriggerAction("updateProgress", map[string]interface{}{
                "progress": i,
            }); err != nil {
                return
            }
        }
    }()
    return state, nil
}

AsyncOpsController is simpler — reconnecting while loading should just reset to idle (the in-flight "result" is lost):

func (c *AsyncOpsController) OnConnect(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) {
    if state.Status == "loading" {
        state.Status = ""
    }
    return state, nil
}

Neither reconnect path has a test. Worth adding at minimum a comment in the test that reconnect semantics are out of scope, or adding a subtest.


Template deviation: <mark> for errors vs. CLAUDE.md's <del>

async-operations.tmpl uses <mark> for the error string with a comment explaining the rationale:

{{with .Error}}<p aria-live="assertive"><mark>{{.}}</mark></p>{{end}}

CLAUDE.md is explicit: <del> is for error content. The inline comment argues that FlashTag is the "primary" error indicator and <mark> is a "secondary highlight" — but this is a patterns example, and deviating from the project conventions here teaches readers the wrong pattern. The deviation comment itself adds 5 lines of noise.

The established pattern for a secondary error detail string is <del style="display:block;text-decoration:none">. If that style feels heavy for inline error text, the right fix is to propose a CSS rule in livetemplate.css, not to deviate per-template. Suggest reverting to <del> here.


Minor: map[string]interface{} throughout

Go 1.18+ unifies these as map[string]any. The rest of the codebase uses any in similar positions (quick grep confirms). Worth a find-replace in handlers_loading.go for consistency.


Positive notes

  • Re-entrancy guards are ordered correctly — session nil-check before mutating state in all three controllers. The comment explaining why (so Running/Status can't get stuck if the framework regresses) is exactly the right level of documentation for an example.
  • Cancellation pattern is consistent — all goroutines use if err := …TriggerAction(…); err != nil { return }. Easy to learn from.
  • Test quality is high — condition-based waits throughout, no chromedp.Sleep, intermediate-progress assertion in TestProgressBar correctly catches a "jumps straight to 100" regression, and the concurrent-Fetch test is well-scoped as a protocol test (appropriately using liveTemplateClient.send).
  • go.mod cleanup is a net positive — removing 20 stale indirect deps is meaningful housekeeping.
  • Regression test in login_test.go is a nice addition. The #server-welcome-message id targeting is exactly right.

Summary

Item
🔴 Stuck state on reconnect for ProgressBarController and AsyncOpsController — no OnConnect recovery
🟡 <mark> deviation from CLAUDE.md convention in async-operations.tmpl
🟢 map[string]interface{}map[string]any (cosmetic)

The reconnect issue is the only one that affects correctness; the template deviation affects example quality. Happy to approve once those two are addressed.

🤖 Generated with Claude Code

- 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) <noreply@anthropic.com>
@adnaan
Copy link
Copy Markdown
Contributor Author

adnaan commented Apr 15, 2026

Thanks for the careful review. Round 5 pushed as 81ae907. Quick notes on each item:

🔴 "Stuck after reconnect" — addressed by clarifying comments, no behavior change

The bug as described cannot occur in this PR because the state structs have no lvt:"persist" tags. Walking through the framework:

  1. mount.go:401 calls restorePersistedState(ctx, groupID) on every WebSocket connect.
  2. In mount.go:1370-1372, restorePersistedState short-circuits to (nil, false) when h.persistable == nil.
  3. h.persistable is non-nil only when the state struct has lvt:"persist" fields. ProgressBarState and AsyncOpsState have none.
  4. So mount.go:404 falls through to cloneStateTyped(), which produces fresh zero-value state — Running=false, Status="", etc.

Result: every reconnect gives the user a clean slate with the Start/Fetch button visible. The re-entrancy guards (if state.Running / if state.Status == "loading") are evaluated against the fresh state, not stale state, so they never block recovery. I confirmed this by reading mount.go:396-420 and tracing through restorePersistedState.

LazyLoadController needs OnConnect for a different reason — the spinner-to-data swap is the whole point of that pattern, so the initial GET ships Loading=true from Mount() and the reconnect path needs to re-fire the goroutine. ProgressBar and AsyncOps are about the goroutine push itself — a mid-run disconnect simply ends the demo run, and the user clicks Start/Fetch again on the fresh page.

I added explanatory comments on both controllers documenting this so future reviewers don't re-raise the concern. Adding a recovery OnConnect would actually make things worse — it would try to "restore" a Status="loading" state pointing at a goroutine the framework has already torn down.

🟡 <mark> deviation — keeping as-is per maintainer decision

The user explicitly asked to keep <mark> here over <del>: "mark is better since it's shorter; we can even style it in our client's livetemplate.css on top of picocss to make it semantic and crisp". The inline comment documents this is a deliberate deviation, with the stronger styling deferred to a future CSS PR. Not changing.

🟢 map[string]interface{}map[string]any — fixed

Fixed in 81ae907. Linter was flagging the same thing. Matches Go 1.18+ idiom.

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — PR #70: Loading & Progress Patterns (#14#16)

Overview

Adds three server-push patterns using session.TriggerAction from background goroutines — lazy loading, a progress bar, and an async operation state machine. The PR also bumps the library to v0.8.18 (which fixes the Session wiring that makes all three patterns actually work), cleans up ~20 stale transitive dependencies, and adds a regression guard in the login example.

The implementation is well-structured and the inline comments show clear reasoning. A few items worth discussing below.


Positives

Goroutine safety is solid. All three controllers follow the same pattern:

  1. Check session != nil before mutating state (so a framework regression leaves the UI interactive rather than stuck)
  2. Spawn the goroutine
  3. Exit cleanly on TriggerAction error (session disconnected)

The ordering of the nil-check before the state mutation in ProgressBarController.Start (check before Running = true) and AsyncOpsController.Fetch (check before Status = "loading") is exactly right — it prevents permanently stuck UI states if the framework ever regresses.

Re-entrancy guards are correct. Running in ProgressBarController and status == "loading" in AsyncOpsController prevent concurrent goroutine stacking from a direct WebSocket message. The comments explaining why this matters (UI disabling doesn't protect against raw WS sends) are valuable for readers learning the pattern.

Test quality is high. Tests use WaitFor/WaitForText throughout — no chromedp.Sleep. The Concurrent_Fetch_Reaches_Single_Result test correctly uses liveTemplateClient.send() directly to bypass the template-disabled button (this is the appropriate use of the WS bypass per CLAUDE.md). The intermediate-tick assertion in TestProgressBar is a smart way to verify the goroutine is actually ticking rather than skipping to 100.

Login regression test is valuable. The sendWelcomeMessage goroutine was silently no-op'ing before v0.8.18, and the existing test was passing only because it checked the template-rendered literal, not the pushed message. The new subtest with #server-welcome-message and the explicit id on the <ins> element is a good, targeted fix.

go mod tidy cleanup is appropriate. Removing the charmbracelet TUI, gofakeit, goose, and other packages that are no longer referenced is straightforward housekeeping.


Issues

1. <mark> for error content deviates from CLAUDE.md without escalation

async-operations.tmpl uses <mark> to display the error string, with an inline comment explaining the choice. CLAUDE.md says: "Use <del> for removed/error content." The reasoning in the comment is defensible (FlashTag is the primary error indicator, <mark> is a secondary highlight), but this is the kind of deviation that should be a deliberate sign-off rather than a self-approved inline comment. Consider either:

  • Switching to <del style="display:block;text-decoration:none">{{.}}</del> per the standard, or
  • Getting an explicit reviewer nod that <mark> is preferred here before merging

2. TestProgressBar.Run_Again_Restarts_Timer doesn't verify completion

The subtest clicks "Run Again," waits for a mid-flight progress value, then waits for the "Run Again" button to reappear — but it never asserts the success flash arrives the second time. CLAUDE.md requires asserting full page state after each mutation. A one-line addition covers it:

e2etest.WaitForText(`output[data-flash="success"]`, "Job complete", 3*time.Second),

3. Lazy-load reconnect race relies on undocumented framework semantics

The comment in OnConnect explains that a reconnect within the 2s window spawns two goroutines, and the first one's TriggerAction "will return an error when its session becomes invalid." This relies on the framework invalidating the old session on reconnect, which is reasonable — but livetemplate#336 (the Session wiring PR) should be checked to confirm this is guaranteed. If the framework doesn't invalidate old sessions on reconnect, both goroutines could push to the same session and the user sees a double-update. This is low risk for a demo, but worth a quick confirmation.

4. lvt pseudo-version dependency

github.com/livetemplate/lvt v0.1.4-0.20260410132914-2f543135f074 is still a pseudo-version. Not blocking, but should be tracked for when it gets a proper tag so downstream projects depending on these examples don't have to pin to a pseudo-version.


Minor / Nits

  • lazyLoadDelay and asyncFetchDelay are both 2 * time.Second — consider a single shared const if these are intentionally the same, or keep them separate if they may diverge. Current approach (separate consts) is fine.
  • The Mount guard if ctx.Action() == "" in LazyLoadController is correct and well-commented. Ensure this is tested — the existing Reload_Refetches_Fresh_Content subtest exercises the Reload POST but doesn't explicitly assert that a mid-loading Reload (clicking Reload while still loading) behaves correctly. Low priority since the template hides the button during loading.
  • Template comment in async-operations.tmpl uses {{/* ... */}} correctly (no inline JS), good.

Summary

The core implementation is sound — goroutine safety, state machine design, and test structure are all well done. The two actionable items are the <mark> vs <del> deviation (needs explicit sign-off or correction) and the missing flash assertion in Run_Again_Restarts_Timer. The lazy-load reconnect semantics question is low risk but worth a quick answer.

🤖 Generated with Claude Code

- 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) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — PR #70: Patterns Session 3 (Loading & Progress, #14–16)

Overall: Well-structured implementation with thoughtful defensive programming, clear inline design rationale, and solid E2E coverage. Dependency cleanup is tidy. A few items worth discussing below.


Code Quality

Strengths:

  • State structs lead with Title/Category as required by the layout convention.
  • Session nil checks are ordered correctly — check before mutating state in all three controllers. The ProgressBarController.Start and AsyncOpsController.Fetch examples are a good habit; the comment explaining why ordering matters is worth keeping for readers learning the pattern.
  • Goroutine cancellation is consistent: if err := session.TriggerAction(...); err != nil { return } in all loops and one-shots.
  • The Reload mount guard (if ctx.Action() == "") correctly prevents a POST action from resetting loading state mid-goroutine.

Minor style nit — JS string concatenation in test:

// patterns_test.go ~line 835
`(() => { const el = document.querySelector('output[data-flash="`+outcome+`"]'); return el ? el.textContent.trim() : ""; })()`

outcome is constrained to "success"/"error" so there's no injection risk, but fmt.Sprintf(...) would be idiomatic for a Go test and easier to scan.


Potential Issues

1. LazyLoadController.Reload lacks the re-entrancy guard that ProgressBarController.Start and AsyncOpsController.Fetch have.

The comment in Reload says the template hides the button while Loading=true, but direct WebSocket messages bypass the rendered UI (and Concurrent_Fetch_Reaches_Single_Result in TestAsyncOperations proves that's a tested scenario). A direct WS {action:"reload"} while Loading=true would spawn a second goroutine alongside the first, both of which will call TriggerAction("dataLoaded") and write to state.Data. The comment documents this as "harmless" (the newer timestamp overwrites), which is true for a demo, but the asymmetry with the other two controllers is a minor trap for readers who pattern-match from this file.

Suggestion: either add the guard for consistency, or lift the asymmetry note out of the inline comment into a brief // Note: at the start of the function so it's easier to find.

2. Reconnect-during-loading goroutine duplication (acknowledged in comments).

The OnConnect comment explains that two goroutines can fire if the client reconnects within the 2s window. The analysis is correct and the demo behaviour is harmless. For a production-targeted example, a single-flight in-flight-ID approach (as the comment suggests) would be cleaner, but this is fine for a patterns demo. Just noting it for the review record.


Template Concerns

<mark> deviation from CLAUDE.md error guidance (async-operations.tmpl):

The inline comment explains the reasoning (FlashTag is the primary role="alert" indicator; <mark> is a secondary highlight). The explanation is reasonable and the deviation is documented. However, CLAUDE.md says <del> for errors — if you want to keep <mark>, consider adding a note to CLAUDE.md under the CSS section so future example authors understand when <mark> is acceptable for secondary error details vs. <del> for primary ones.

progress-bar.tmpl — FlashTag placement:

The {{.lvt.FlashTag "success"}} is inside the {{else if .Done}} block, which is correct since ctx.SetFlash("success") is only called in UpdateProgress when Progress >= 100 (which also sets Done=true). No issue — just confirming the placement is intentional and correct.


Test Coverage

Good overall. Specific observations:

  • All controller methods exercised ✓ — LazyLoad{DataLoaded, Reload}, ProgressBar{Start, UpdateProgress}, AsyncOps{Fetch, FetchResult}.
  • Concurrent_Fetch_Reaches_Single_Result appropriately uses liveTemplateClient.send() — this is a targeted protocol test, exactly the reserved use case from CLAUDE.md.
  • TestProgressBar.Start_Runs_To_Completion uses an intermediate-tick assertion (value > 0 && value < 100) to catch a regression where the goroutine skips to 100 instantly. That's a nice catch.
  • TestAsyncOperations.Fetch_Transitions_Through_Loading_To_Result correctly tolerates either branch and maps outcome → expected flash text. One minor note: the chromedp.Run call that reads flashText ignores its error with _ = chromedp.Run(...). If the evaluate fails (e.g., race before the flash element is injected), flashText stays "" and t.Errorf fires with a confusing message. A WaitFor on output[data-flash="{outcome}"] before reading .textContent would be more robust.

Regression test in login/login_test.go:

Adding id="server-welcome-message" to auth.html to make the test target stable is the right call. The test accurately distinguishes the template-rendered static text from the server-pushed timestamped message.


Security

No concerns. All rendered content is server-generated strings with no user-input reflection. Templates are CSP-compliant (no inline event handlers, no inline scripts, no inline styles beyond the standard <ins>/<del> block pattern).


Dependencies

go mod tidy cleanup is clean — removing TUI/charmbracelet/gofakeit indirect deps that were genuinely stale is good housekeeping. The v0.8.17 → v0.8.18 bump is well-justified and the PR clearly documents why v0.8.17 silently no-op'd all three patterns.


Summary

Item Severity
Reload missing re-entrancy guard (acknowledged, harmless for demo) Minor
flashText read ignores chromedp.Run error in Fetch_Transitions… test Minor
<mark> vs <del> deviation undocumented in CLAUDE.md Minor
JS string concatenation in flash selector assertion Nit

No blocking issues. The implementation is solid, the design decisions are well-documented, and the test suite is meaningfully comprehensive. The Reload guard asymmetry is the most actionable item if you want consistency across the three controllers.

🤖 Generated with Claude Code

- 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) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — PR #70: Session 3 Loading & Progress Patterns

Overall this is a well-structured, thoughtful PR. The implementation is clean, the goroutine patterns are correct, and the test coverage is solid. A few items worth discussing before merging.


What's Good

Goroutine lifecycle is handled correctly across all three controllers.
The if err := session.TriggerAction(...); err != nil { return } cancellation pattern is consistent, and the ordering of "check session non-nil before mutating state" prevents the stuck-UI scenario described in the PR. The reconnect edge-case analysis in the inline comments is unusually thorough for an example repo — that's genuinely useful for readers learning the pattern.

Tier 1 constructs only. No lvt-* attributes needed for any of these patterns. Standard method="POST" forms and name-routed buttons throughout.

Test quality. No chromedp.Sleep, WaitFor/WaitForText used correctly, every controller method exercised, full page-state assertions, and the Concurrent_Fetch_Reaches_Single_Result subtest is a well-targeted regression guard for the re-entrancy guard logic.

Dependency cleanup. Dropping ~20 stale indirect transitive deps (charmbracelet TUI, clipboard, gofakeit, etc.) is a clean improvement unrelated to the feature changes but worthwhile.


Issues

1. <mark> vs <del> for error detail — deviation from CLAUDE.md should be aligned with maintainers

async-operations.tmpl:59 uses <mark> for the error string and acknowledges the deviation in a comment:

{{with .Error}}<p aria-live="assertive"><mark>{{.}}</mark></p>{{end}}

CLAUDE.md is explicit: <del> for error content, <mark> for highlighted/badge text. The comment explains the reasoning (FlashTag is the primary error indicator; <mark> is a "secondary highlight"), but the convention exists to keep templates consistent. The deviation should be an explicit decision by the maintainer team rather than a unilateral call in the PR. If <mark> is the right call here, update CLAUDE.md to document when this exception applies.

2. JS-disabled clients get a permanent spinner on Lazy Loading

lazy-loading.tmpl renders <p aria-busy="true">Loading content...</p> on initial GET. JS-disabled browsers never establish a WebSocket, so OnConnect never fires and the spinner never resolves. The PR description acknowledges this ("JS-disabled spinner-forever case is created by Mount() returning Loading=true on the initial HTTP GET").

This is acceptable for a patterns demo, but the template should carry a visible note for readers — otherwise a developer reading this example might copy the pattern into a production app without understanding the limitation. A short <p><small>Requires JavaScript</small></p> in the spinner branch, or a <noscript> fallback, would make the constraint explicit in the rendered UI rather than only in Go comments.

3. TestProgressBar.Run_Again_Restarts_Timer — intermediate-value window could be tight on a loaded CI runner

e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value > 0 && document.querySelector('progress').value < 100`, 3*time.Second),

The goroutine ticks every 500ms over 5s (10 ticks). The intermediate-value window (0 < value < 100) is open for the first 4.5s, so a 3s timeout should always find a tick. However, on a heavily loaded CI runner, if the WS message for the first tick is delayed past 3s, this subtest fails spuriously. Consider widening the timeout to 5s, or relying solely on the button text check (Run Again) which already validates the goroutine ran to completion.

4. Goroutine double-fire on reconnect during lazy loading — edge case not mitigated

The inline comment in OnConnect correctly explains the double-fire scenario (two goroutines spawn if the client reconnects within the 2s window), but then dismisses it as harmless because "Loading=false is idempotent." That's correct for this demo, but the comment reads as if it's acceptable production guidance. For a patterns example repo, consider adding one line noting: "Single-flight semantics require a request ID tracked in state and checked in DataLoaded — see [link to production pattern]." This would prevent readers from cargo-culting the simplified demo into production without understanding the trade-off.


Minor Nits

  • handlers_loading.go:283append(slices.Clone(baseOpts), ...) appears identically in all three handler factories. Not worth abstracting for three calls, but if a fourth loading pattern gets added, a small helper would be warranted.
  • The Reload method's comment about "symmetric with ProgressBarController.Start" is useful cross-reference, though readers have to jump between 200 lines to see it. Fine for now.
  • go.sum still contains golang.org/x/sync v0.20.0 hash lines even though it was removed from go.mod indirect — this is expected behavior (go mod tidy keeps hashes for removed deps); no action needed.

Summary

Area Assessment
Goroutine lifecycle Correct — cancellation, session-nil guards, re-entrancy guards all present
Tier compliance Tier 1 only, appropriate
Template HTML Clean semantic markup, one convention deviation to resolve
Test coverage Comprehensive — every controller method, state transitions, concurrency guard
Documentation Excellent inline comments for an example repo

Recommended before merge:

  1. Resolve <mark> vs <del> with maintainers and update CLAUDE.md if <mark> wins.
  2. Add a JS-required note to the lazy-loading spinner branch.
  3. Optionally widen the Run_Again_Restarts_Timer intermediate-value timeout to 5s.

🤖 Generated with Claude Code

- Add <noscript> note to lazy-loading.tmpl spinner branch. JS-disabled
  clients never establish a WebSocket, so the spinner never resolves;
  this fallback makes the constraint visible to readers in the rendered
  UI rather than only in Go comments. The pattern itself is still a
  patterns-demo trade-off (a production lazy-load would render content
  server-side first), so the noscript text says "this pattern requires
  JavaScript" rather than pretending it has a real fallback.
- Widen TestProgressBar.Run_Again_Restarts_Timer intermediate-tick
  timeout from 3s → 5s. The goroutine ticks every 500ms over 5s; on a
  loaded CI runner the first tick can be delayed past 3s and produce a
  spurious failure even when the goroutine is working correctly. The
  outer 'Run Again button appears' assertion already enforces full
  completion within 10s, so widening the inner check is safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — PR #70: Session 3 Loading & Progress Patterns

Overall this is a well-structured PR. The server-push patterns are implemented correctly, the test suite is thorough, and the inline code comments do an unusually good job explaining reconnect semantics and guard ordering. A few things worth addressing before merge:


Issues

1. min() built-in requires Go 1.21+ — verify go.mod

login/login_test.go:244 uses body[:min(len(body), 800)]. The min built-in was added in Go 1.21. The diff doesn't show the go directive in go.mod — if the module declares go 1.20 or earlier this will fail to compile. Worth confirming the module's minimum version covers it.

2. <mark> for error detail deviates from CLAUDE.md without a follow-up path

async-operations.tmpl uses <mark> for the error string instead of <del> (which CLAUDE.md specifies for error content). The inline comment explains the rationale — "primary error indicator is the FlashTag, this is a secondary highlight" — and that reasoning is sound. But the comment ends with "if a stronger error look is wanted later, override mark[aria-live] in livetemplate.css", which suggests the current choice is provisional. Either commit to it and update CLAUDE.md to document <mark> as the pattern for secondary error detail, or use <del> now and revisit later. Leaving it as a one-off deviation in a patterns example is confusing for readers who will model behavior from this file.

3. Duplicate goroutine body between OnConnect and Reload

LazyLoadController.OnConnect and LazyLoadController.Reload both contain the identical goroutine:

go func() {
    time.Sleep(lazyLoadDelay)
    if err := session.TriggerAction("dataLoaded", map[string]any{
        "data": "Content loaded/reloaded lazily at " + time.Now().Format("15:04:05"),
    }); err != nil {
        return
    }
}()

The only difference is the string prefix ("Content loaded lazily at …" vs "Content reloaded at …"). Since this is a patterns example that readers will copy, extracting a small helper func spawnLazyFetch(session Session, label string) would make the "canonical cancellation pattern" explicit without duplicating it. This isn't a bug, just a DRY concern that matters more here than in production code because readers learn the pattern from this file.

4. progress-bar.tmpl: FlashTag placement is inside .Done branch only

{{else if .Done}}
<progress value="100" max="100"></progress>
{{.lvt.FlashTag "success"}}

Flash tags are ephemeral — they're consumed on first render. If the component is re-rendered after the flash is already consumed (e.g., a reconnect after completion), the .Done branch still renders but the flash is gone. That's expected behaviour. However, the idle state and running state don't include a FlashTag slot at all, meaning a flash from a previous run that somehow wasn't consumed could be silently dropped if the user clicks Start again before the flash rendered. This is a minor edge case for a demo, but worth a one-line comment explaining that the Flash is intentionally scoped to the Done branch.


Observations (no action required)

Reconnect race in LazyLoadController.OnConnect: The comment in the goroutine block does a good job explaining the two-goroutine scenario on rapid reconnect. For an example repo this is the right tradeoff; the comment is sufficient. Just confirming this was reviewed and not missed.

Guard ordering in ProgressBarController.Start and AsyncOpsController.Fetch: The pattern of checking session != nil before mutating state (not after) is correct and the comments explain exactly why. This is a subtle point and the rationale is well-documented.

Test suite quality: The three new E2E tests meet all the CLAUDE.md checklist items — every controller method is exercised, full page state is asserted after mutations, real browser interactions are used for primary flows, condition-based waits replace sleep everywhere, and the error path is tested. The Concurrent_Fetch_Reaches_Single_Result subtest's use of liveTemplateClient.send() is the right call here (testing a WebSocket protocol invariant, not a UI flow) and is consistent with the guidance.

go mod tidy cleanup: Removing the charmbracelet/bubbletea/gofakeit indirect deps is a genuine improvement. No concerns there.

Regression test in login_test.go: Targeting #server-welcome-message instead of a bare ins is the right defensive choice. The comment explaining why the existing test didn't catch the regression is a good addition.


Summary: The two items to address before merge are (1) confirming the Go version covers the min() built-in and (2) resolving the <mark> vs <del> question for the patterns example. The goroutine extraction (#3) would improve readability but is not blocking. Everything else looks good.

adnaan and others added 2 commits April 15, 2026 09:18
The async-operations.tmpl error string was using <mark> as a "deliberate
deviation" from CLAUDE.md, with an inline comment explaining the rationale.
This commit promotes that exception into the convention itself: <mark> is
now the documented choice for secondary inline error details (a specific
error string highlighted alongside a primary FlashTag alert that already
carries role="alert"). <del> remains the primary error alert.

Also updates the inline template comment to reference the new convention
instead of explaining a deviation that no longer exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Document why FlashTag is rendered only inside the .Done branch (the
only render path that emits a "success" flash). The idle and Running
branches don't need a FlashTag slot because no controller code path
emits a flash while either is the active render state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — Session 3: Loading & Progress (Patterns #14–16)

Overall this is a well-executed PR. The core mechanics are correct, the goroutine patterns are idiomatic, and the test suite follows CLAUDE.md's E2E rules closely. A few things worth discussing before merge.


Correctness / Bugs

No blocking bugs found. The three state machines are implemented correctly and the goroutine cancellation pattern (if err := session.TriggerAction(...); err != nil { return }) is consistent throughout.

One subtle point worth calling out explicitly: in LazyLoadController.OnConnect, if the client reconnects within the 2-second fetch window, two goroutines race to call TriggerAction("dataLoaded"). The PR's inline comment explains the two outcomes (both harmless for a demo). This is fine as-is, but the comment is buried in a 17-line block that will be the first thing pattern-matching readers see. Consider a one-sentence summary at the top of the block, with the detail in a follow-up paragraph:

// If the client reconnects during the 2s window, two goroutines may dispatch.
// Both outcomes are harmless (see below); production callers should use
// single-flight semantics instead.

Test Coverage

Tests satisfy all CLAUDE.md E2E rules:

  • ✅ Every controller method exercised (Mount/OnConnect/DataLoaded/Reload, Start/UpdateProgress, Fetch/FetchResult)
  • WaitFor/WaitForText used everywhere — no chromedp.Sleep
  • ✅ Real browser clicks for primary flows; liveTemplateClient.send() only in the Concurrent_Fetch guard test (explicitly sanctioned by CLAUDE.md for WS protocol tests)
  • ✅ Both success and error branches of AsyncOpsController are asserted

One gap: TestProgressBar.Run_Again_Restarts_Timer jumps straight to the intermediate-tick wait without first confirming the <progress> element appeared. Start_Runs_To_Completion does this correctly with an explicit WaitFor('!!document.querySelector("progress")', 3s) step. If the second run's first tick fires before the assertion window opens, the intermediate check could race. Low probability, but Start_Runs_To_Completion's pattern should be mirrored here.

Concern with the async non-determinism: TestAsyncOperations.Fetch_Transitions_Through_Loading_To_Result correctly handles both branches, but the rand.Intn(3) == 0 failure path means ~33% of CI runs will exercise the error branch. If the CI dashboard shows test timing outliers, this is why — both branches should produce roughly the same wall time (both wait 2s), but worth being aware of.


CLAUDE.md Update

The new <mark> guidance (line 14 of the diff) packs a lot in one bullet:

Use <mark> for highlighted/badge text and for secondary inline error details … Pair <mark> with aria-live="assertive" on the surrounding element when the highlighted text is itself the error detail being announced.

This is accurate for the async-operations template, but the aria-live="assertive" sub-rule is specific to the case where <mark> is the only a11y signal (no role="alert" ancestor). The current wording doesn't make that condition clear — a future contributor could apply it even when there's already a FlashTag with role="alert" on the page, creating redundant assertive announcements that interrupt screen reader flow.

Suggested revision:

- Use `<mark>` for highlighted/badge text and for secondary inline error details alongside a primary `FlashTag` alert. Add `aria-live="assertive"` on the surrounding element only when `<mark>` is the sole a11y signal — omit it if a `FlashTag` with `role="alert"` already announces the error.

Minor

  • handlers_loading.go comments are thorough — almost to a fault in places. The reconnect analysis in OnConnect (lines 355–371 in the new file) is 17 lines for a scenario that's stated to be "harmless." For an examples repo where readers are pattern-matching, consider collapsing to 5–6 lines and linking to the Server Push pattern (refactor: migrate component imports to lvt monorepo #31) for the full treatment.

  • login/login_test.go line 262 uses body[:min(len(body), 800)] — the min built-in requires Go 1.21+. If the module's go directive is ≥ 1.21 this is fine, just worth confirming.

  • go.mod cleanup (removing charmbracelet, gofakeit, pressly/goose, etc.) is welcome. Purely mechanical, no concerns.


What's Done Well

  • Session nil check ordering (check before mutating state, not after) is the right defensive pattern and is well-explained in every controller.
  • slices.Clone(baseOpts) before append in each handler factory prevents cross-handler option mutation — exactly right.
  • <noscript> fallback in lazy-loading.tmpl is a good UX touch.
  • The login regression subtest (Server_Welcome_Message_via_WebSocket_Push) is a good addition — the existing test would have silently masked the ctx.Session() == nil bug indefinitely.
  • Templates use semantic HTML throughout (<progress>, <blockquote>, <mark>, aria-busy, aria-live).

Verdict: Approve with suggestions. The CLAUDE.md <mark> wording and the Run_Again_Restarts_Timer missing <progress> existence wait are the two I'd most like addressed before merge. The rest is advisory.

🤖 Generated with Claude Code

@adnaan
Copy link
Copy Markdown
Contributor Author

adnaan commented Apr 15, 2026

Round 5 status — addressing the latest review items:

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — Session 3: Loading & Progress (Patterns #14–16)

This is a solid, well-documented implementation. The goroutine-push patterns are correctly structured and the E2E tests are genuinely thorough. Below are observations ordered roughly by priority.


Issues / Suggestions

1. Goroutine duplication in LazyLoadController (handlers_loading.go)

OnConnect and Reload both contain the same goroutine body, differing only in the timestamp label string:

go func() {
    time.Sleep(lazyLoadDelay)
    if err := session.TriggerAction("dataLoaded", map[string]any{
        "data": "Content loaded lazily at " + time.Now().Format("15:04:05"),
    }); err != nil {
        return
    }
}()

vs.

"data": "Content reloaded at " + time.Now().Format("15:04:05"),

Since the label is the only difference, a small unexported helper would eliminate the duplication without being a premature abstraction:

func spawnLazyLoad(session livetemplate.Session, label string) {
    go func() {
        time.Sleep(lazyLoadDelay)
        if err := session.TriggerAction("dataLoaded", map[string]any{
            "data": label + time.Now().Format("15:04:05"),
        }); err != nil {
            return
        }
    }()
}

The test relies on the exact prefix strings ("Content loaded lazily", "Content reloaded"), so extracting the helper also makes the label-to-test binding explicit rather than implicit.

2. TestAsyncOperations.Concurrent_Fetch_Reaches_Single_Result bypasses the button — this is correct but worth a naming note

The test comment correctly justifies using window.liveTemplateClient.send() directly (CLAUDE.md allows it for "targeted WebSocket protocol tests"). However the comment says the guard is "hard to detect from the rendered HTML because the state machine is idempotent in its final state." That's true but slightly undersells what the test does prove: resultCount != 1 catches cases where two goroutines raced and left two <blockquote> or two <mark> elements, or left zero due to a state-machine tear. The assertion is meaningful; it just can't prove the guard fired on the second call. The comment could note that explicitly.

3. FlashTag placement in progress-bar.tmpl is subtly load-bearing

The FlashTag "success" is only rendered inside the {{else if .Done}} branch. This works correctly because UpdateProgress sets Done=true and ctx.SetFlash("success", ...) in the same controller call, so the flash is always consumed on the first render of the Done state. But this is non-obvious — a future maintainer might move the FlashTag outside the branch "to make it always available," breaking the pattern. A one-line comment would protect it:

{{/* FlashTag must live inside the Done branch: the controller emits the
     flash only when transitioning to Done, so they always render together.
     Moving this outside the branch would cause the flash to be consumed
     during a Running or idle render before Done is reached. */}}
{{.lvt.FlashTag "success"}}

4. TestProgressBar.Start_Runs_To_Completion — intermediate tick assertion is timing-sensitive

The test asserts progress.value > 0 && progress.value < 100 with a 3s timeout. The first tick arrives at ~500ms after clicking Start, so 3s is generous on a healthy machine. However, on a heavily loaded CI runner with slow WS delivery, there's a window where the first tick arrives, WS-dispatches through the Go controller, and the next tick fires before the JS assertion is evaluated — all within 3s. This is unlikely but possible if the machine is fast. The Run_Again_Restarts_Timer subtest already bumped its equivalent timeout to 5s for this reason; applying the same 5s to Start_Runs_To_Completion would make both consistent.

5. CLAUDE.md update is verbose for a reference document

The new <mark> guideline is accurate but dense:

Use <mark> for highlighted/badge text and for secondary inline error details (e.g., a specific error string highlighted alongside a primary FlashTag alert that already has role="alert"). When in doubt: <del> is the primary alert; <mark> is a secondary highlight on the same page. Pair <mark> with aria-live="assertive" on the surrounding element when the highlighted text is itself the error detail being announced.

This is 60+ words for one bullet. Consider splitting into the summary rule + a See also cross-reference pointing to async-operations.tmpl, or shortening to: Use <mark> for highlighted/badge text and for secondary inline error detail alongside a primary FlashTag alert. Wrap in \aria-live="assertive"` when the detail is the sole announcement.`


What's working well

  • Session guard ordering is correct throughout — checking session != nil before mutating state (rather than after) prevents the UI from getting stuck in Loading=true/Running=true with no goroutine to ever clear it. This is the non-obvious ordering and it's right.
  • OnConnect skip guard in LazyLoadControllerif !state.Loading { return } correctly handles reconnects after data has arrived.
  • No chromedp.Sleep anywhere in the new tests — all timing is condition-based (WaitFor, WaitForText, WaitForCount).
  • Concurrent_Fetch_Reaches_Single_Result uses direct WS send correctly and the assertion (resultCount == 1) is meaningful.
  • TestLazyLoading.Reload_Refetches_Fresh_Content asserts distinct prefix strings rather than a simple inequality — a simple firstContent != secondContent check would trivially pass since timestamps differ; the prefix assertions actually prove different goroutine code paths ran.
  • go.mod cleanup — removing 20+ stale Charmbracelet/TUI/gofakeit indirect deps is correct and keeps the module graph lean.
  • Login regression test is a good backstop: the existing "Successful Login" subtest would still pass even if sendWelcomeMessage silently no-op'd, making the new subtest the only real guard.
  • noscript in lazy-loading.tmpl honestly describes the JS-disabled limitation without pretending the pattern degrades gracefully.

CI note

The test plan has ./test-all.sh unchecked. Given that this PR touches the shared go.mod/go.sum and adds routes to the patterns mux, a full cross-example run is especially worth completing before merge to surface any indirect-dependency or mux-ordering regressions.


Overall this is clean and well-thought-out. The minor items above are refinements, not blockers.

🤖 Generated with Claude Code

- Sharpen progress-bar.tmpl FlashTag comment to explain the failure
  mode if the FlashTag is moved outside the .Done branch (the flash
  would be consumed during a Running/idle render before Done is
  reached, and the user would never see it). The previous comment
  described the design but didn't make the load-bearing constraint
  obvious to a future maintainer.
- Bump TestProgressBar.Start_Runs_To_Completion intermediate-tick
  timeout from 3s → 5s for consistency with Run_Again_Restarts_Timer
  and to give loaded CI runners headroom before the goroutine
  completes the full 5s run.
- Shorten CLAUDE.md <mark> guideline from 60 words to ~30 while
  preserving the rule, the FlashTag pairing, the aria-live note,
  and the "primary vs secondary" rule of thumb.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@adnaan
Copy link
Copy Markdown
Contributor Author

adnaan commented Apr 15, 2026

Round 6 status — addressing the latest review of `b813143`:

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — Session 3: Loading & Progress (Patterns #14#16)

Overview

Implements three server-push patterns via session.TriggerAction from background goroutines. The dependency on v0.8.18 is clearly justified — the PR description explains why v0.8.17 was broken and adds a login regression guard to prove the fix. Overall a well-structured, well-documented addition.


What's Working Well

Controller design

  • Session nil-check ordering is correct throughout: check ctx.Session() before mutating state, so a framework regression never leaves the UI in a stuck state (Running=true, no goroutine to clear it). The comments explaining why this order matters are worth keeping.
  • Re-entrancy guards on all three action handlers are correctly placed and explained.
  • Goroutine cancellation via if err := session.TriggerAction(...); err != nil { return } is the right idiomatic pattern.
  • slices.Clone(baseOpts) in every handler factory is correct defensive practice.

Tests

  • All controller methods are exercised.
  • Intermediate-tick assertion in Start_Runs_To_Completion (value > 0 AND value < 100) is a nice regression catch — a "jump to 100" regression would pass a naïve value > 0 check.
  • Concurrent_Fetch_Reaches_Single_Result correctly uses liveTemplateClient.send() for the targeted reentrancy test (CLAUDE.md explicitly allows this for WebSocket protocol tests).
  • No chromedp.Sleep anywhere — condition-based waits throughout.
  • Login regression subtest and #server-welcome-message id are a clean, targeted way to guard the TriggerAction fix without over-reaching.

Templates

  • <noscript> fallback in lazy-loading.tmpl is thoughtful.
  • FlashTag placement inside the .Done branch in progress-bar.tmpl is correct, and the comment explaining why it belongs there (flashes are ephemeral, consumed on first render) is exactly the kind of explanation future maintainers need.
  • {{with .Result}} / {{with .Error}} in async-operations.tmpl cleanly avoids duplicated state-machine logic in the template.

Issues

Minor: progress-bar.tmpl Done branch hardcodes value="100" instead of {{.Progress}}

{{else if .Done}}
<progress value="100" max="100"></progress>

When Done=true the controller always sets Progress=100, so this renders correctly. But using {{.Progress}} instead of the literal 100 would be more consistent with the Running branch and would survive any future change to the controller's terminal value without silently showing 100 when the actual state says otherwise.

Minor: CLAUDE.md <mark> rule update is verbose

The new rule:

Use <mark> for highlighted/badge text and for secondary inline error detail alongside a primary FlashTag/<del> alert. Wrap in aria-live="assertive" when the detail is the sole announcement. (Rule of thumb: <del> is the primary alert; <mark> is a secondary highlight on the same page.)

This is accurate but dense. The parenthetical rule-of-thumb is good guidance but the sentence before it already says the same thing differently. Consider trimming:

- Use `<mark>` for highlighted/badge text. When used as secondary error detail alongside a `FlashTag`/`<del>` primary alert, the `<del>` is the primary announcement; `<mark>` highlights the specific value. Add `aria-live="assertive"` only when `<mark>` is the sole error announcement (no accompanying `FlashTag`).

Informational: go mod tidy cleanup is included in the diff

~20 stale indirect dependencies (charmbracelet, gofakeit, goose, etc.) were removed. This is clean and correct, but it adds noise to the review diff. If your project convention prefers keeping dependency changes in a separate commit, worth noting for future PRs. Not a blocker here.


Reconnect-During-Loading Semantics (FYI, not a blocker)

The comments in OnConnect correctly describe the two reconnect cases:

  • (a) Reconnect before goroutine wakes → TriggerAction returns "no connected sessions" → goroutine exits cleanly.
  • (b) Reconnect during the 2s window → both goroutines dispatch; second timestamp overwrites the first harmlessly.

This is fine for a demo. If this were production code, a sync/atomic flag or single-flight guard in state would prevent case (b). The existing comment is sufficient to guide readers who want production semantics — no change needed, just flagging for visibility.


Summary

The implementation is solid. Two actionable suggestions:

  1. progress-bar.tmpl: change value="100"value="{{.Progress}}" in the Done branch for consistency.
  2. CLAUDE.md: consider trimming the new <mark> rule to reduce density.

Everything else is ready to merge.

Use {{.Progress}} instead of literal "100" in the .Done branch of
progress-bar.tmpl. The controller currently sets Progress=100 whenever
Done=true, so the rendered output is identical, but binding to .Progress
makes the template survive any future controller change to the terminal
value (e.g., capping at 99 if a follow-up adds a "still finalising"
state) without silently showing a stale 100.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@adnaan
Copy link
Copy Markdown
Contributor Author

adnaan commented Apr 15, 2026

Round 7:

  • refactor: update to use Must(New()) pattern #1 `progress-bar.tmpl` Done branch `value="100"` → `value="{{.Progress}}"` — Done in `690cdbd`. Now consistent with the Running branch, and survives any future change to the controller's terminal value.
  • refactor: update to use Must(New()) pattern for livetemplate v0.3.0 #2 CLAUDE.md `` rule trim — Declining. Last round trimmed it from 60 → ~30 words; the new suggestion is ~50 words. The current wording is concise enough — further iteration risks losing either the FlashTag pairing or the aria-live note. The rule reads cleanly enough as-is.

Two consecutive rounds of cosmetic-only items suggests the PR is converging. Will run `./test-all.sh` once and then ping for merge sign-off.

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review: Session 3 — Loading & Progress Patterns (#14–16)

Overall: This is a well-implemented PR that follows project conventions closely. The server-push architecture is cleanly modeled, comments explain design decisions at the right level of detail, and the tests are thorough. A few observations below, roughly in priority order.


Potential Issue: ctx.GetBool("success") in FetchResult

The PR description specifically calls out that v0.8.18 fixes native Go numeric type coercion in GetIntOk / GetFloatOk. The AsyncOpsController.FetchResult uses ctx.GetBool("success"), but the boolean coercion path isn't mentioned in the fix. If GetBool had the same pre-fix behavior as GetIntOk (silently returning the zero value when the underlying type isn't a Go bool), then fetchResult payloads with "success": true (a Go bool) would work, but it's worth confirming GetBool is exercised by the existing test suite against the v0.8.18 library so no silent regression hides here.


Observation: Duplicate <progress> markup in progress-bar.tmpl

Lines 21-22 and 24-25 of progress-bar.tmpl are identical across the .Running and .Done branches:

<progress value="{{.Progress}}" max="100"></progress>
<p><small>{{.Progress}}% complete</small></p>

Go templates don't have a clean way to factor this without adding complexity, so the duplication is understandable. However, if a styling change is needed later (e.g., adding a max attribute variant, or changing the label), both branches need editing. A comment noting the intentional duplication would help future readers who might miss the second copy.


Minor: Concurrent_Fetch_Reaches_Single_Result uses liveTemplateClient.send()

The CLAUDE.md guidance allows liveTemplateClient.send() for "targeted WebSocket protocol tests." This test is explicitly testing the re-entrancy guard by bypassing the disabled button, so the usage is appropriate. That said, the test comment already explains this well — no change needed, just noting it's intentionally permitted.


Minor: Reload with no-JS path

In LazyLoadController.Reload, the session nil-check is before state.Loading = true, so an HTTP POST fallback (JS disabled) returns early with state unchanged — the page re-renders as-is without transitioning to loading. This is silently correct for a demo that explicitly requires WebSocket, and the lazy-loading.tmpl <noscript> note covers it. The progress-bar.tmpl and async-operations.tmpl don't have analogous <noscript> notes — minor omission, not a blocker.


Positives worth calling out

  • session nil check before state mutation in all three controllers — The ordering (check nil → mutate → spawn goroutine) correctly prevents stuck Loading=true / Running=true states if the framework regresses. This is exactly the right defensive pattern.
  • slices.Clone(baseOpts) in all handler factories — Prevents the shared base options slice from being corrupted by append. Consistent across all three.
  • Tests cover all controller methodsLazyLoadController (Mount/OnConnect/DataLoaded/Reload), ProgressBarController (Start/UpdateProgress), AsyncOpsController (Fetch/FetchResult) are all exercised.
  • No chromedp.Sleep — All waits use e2etest.WaitFor / WaitForText with explicit timeouts. CI-friendly.
  • TestProgressBar.Start_Runs_To_Completion — The intermediate-tick assertion (value > 0 && value < 100) is a smart catch for a goroutine that skips ticks and jumps straight to 100. Most reviewers would miss that regression; the test catches it by design.
  • Login regression subtest — Pinning #server-welcome-message ID rather than a bare ins selector to prevent false-positive matches from future flash elements is the right defensive instinct.
  • go mod tidy cleanup — Removing ~20 stale charmbracelet/TUI/gofakeit transitive dependencies is pure cleanup with no functional risk, and the resulting go.mod is meaningfully smaller.

Summary: The core implementation is solid. The one item worth a quick verification before merge is the GetBool coercion behavior in v0.8.18. Everything else is either a minor observation or a conscious trade-off that's already well-documented in comments.

🤖 Generated with Claude Code

Factor the <progress> + percent label out of both the .Running and
.Done branches into a single {{if or .Running .Done}} block, and
restructure the form/button branches as {{if .Done}}{{else if not
.Running}}{{end}}. The rendered output is identical in all three
states (idle, running, done), but the template no longer duplicates
the <progress value="{{.Progress}}" max="100"> markup across two
branches — a future styling change only needs to touch one place.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@adnaan
Copy link
Copy Markdown
Contributor Author

adnaan commented Apr 15, 2026

Round 8:

  • refactor: update to use Must(New()) pattern #1 `ctx.GetBool("success")` v0.8.18 verification — Already covered. `TestAsyncOperations.Fetch_Transitions_Through_Loading_To_Result` exercises the full path end-to-end: the goroutine sends `{"success": true|false}` via `session.TriggerAction`, `FetchResult` reads it via `ctx.GetBool`, and the test asserts the rendered branch (blockquote vs mark) plus the matching flash text. The test passes against v0.8.18 — that is the verification. `GetBool` on a Go `bool` value works correctly; the v0.8.18 fix was specifically for `GetIntOk` / `GetFloatOk` numeric type coercion (bool isn't numeric).
  • refactor: update to use Must(New()) pattern for livetemplate v0.3.0 #2 Duplicate `` markup — Refactored in `e75e083`. Factored the `` + percent label out of both branches into a single `{{if or .Running .Done}}` block; the form/button branches are now `{{if .Done}}{{else if not .Running}}{{end}}`. No more duplication; future styling changes touch one place.
  • feat: add comprehensive E2E test and idempotency for avatar upload #3 Concurrent_Fetch via send() — Acknowledged-as-correct, no change.
  • refactor: consolidate test files and remove internal package dependencies #4 Reload no-JS — The progress-bar and async-operations patterns intentionally omit the noscript note because, unlike lazy-loading (which renders `

    ` on initial GET and would spin forever without JS), they render their initial state with a clickable button. A no-JS user clicking the button gets a real HTTP POST that re-renders the page, which is partially functional (the click works, the goroutine doesn't push updates). This degrades gracefully enough that a noscript notice would be over-communicated.

PR is converged on functional items. CI green, tests pass locally including the full `./test-all.sh` run.

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — PR #70: Loading & Progress patterns (#14–16)

Overall this is a high-quality PR. The implementation is well-structured, the comments are educational, and the test coverage is thorough. A few observations below, mostly minor.


What's good

Goroutine cancellation pattern — the if err := session.TriggerAction(...); err != nil { return } idiom is applied consistently across all three controllers. This is the right shape for framework-pushed goroutines and good for readers learning from these examples.

Ordering of the session == nil guard — checking session before mutating state in Reload, Start, and Fetch is subtle but important. The comments explain exactly why (avoids leaving Running=true / Status="loading" with no goroutine to clear it). This is the kind of detail that's easy to get backwards and hard to debug.

Test quality — no chromedp.Sleep, all waits are condition-based, the intermediate-tick assertion in Start_Runs_To_Completion catches the "jumps straight to 100" regression class, and the concurrent-fetch test correctly uses liveTemplateClient.send() as permitted by CLAUDE.md (targeted WebSocket protocol test, not a primary CRUD flow).

slices.Clone in handler factories — safe append idiom; consistent with the rest of the patterns handlers.

Login regression test — the new Server_Welcome_Message_via_WebSocket_Push subtest is a good defensive move. The comment explaining why the existing test could pass even with a broken session is exactly the kind of context that makes regression guards useful long-term.


Issues / suggestions

1. noscript placement in lazy-loading.tmpl

{{if .Loading}}
<p aria-busy="true">Loading content...</p>
<noscript>
    <p><small>This pattern requires JavaScript...</small></p>
</noscript>
{{else}}
<blockquote>{{.Data}}</blockquote>
...
{{end}}

The <noscript> is inside the {{if .Loading}} branch. With JS disabled the page will always render with Loading=true (correct), but the noscript message is sandwiched between <p aria-busy="true"> and the data block. Moving <noscript> outside the {{if}} block (or above it) would make the fallback message unconditional and avoid any future confusion if the branching changes:

<noscript>
    <p><small>This pattern requires JavaScript...</small></p>
</noscript>
{{if .Loading}}
...

Minor cosmetic issue — the current placement works correctly in practice.

2. The reconnect-during-loading double-goroutine comment is very long

The 15-line comment in OnConnect (lines 355–370) is thorough, but the cases (a) and (b) are implementation details that don't affect how to use the pattern. For an examples repo, consider trimming it to the key takeaway:

Reconnect within the 2s window can spawn a second goroutine. Both goroutines exit cleanly via TriggerAction error on disconnect, or complete idempotently (the second timestamp overwrites the first — harmless). Production code requiring single-flight semantics should track request ID in state.

3. TestAsyncOperations — flash race window

In Fetch_Transitions_Through_Loading_To_Result, the test reads outcome from the DOM first, then issues a separate WaitFor for the flash element:

_ = chromedp.Run(ctx, chromedp.Evaluate(`...`, &outcome))
// ...
err = chromedp.Run(ctx,
    e2etest.WaitFor(fmt.Sprintf(`!!document.querySelector('output[data-flash="%s"]')`, outcome), 3*time.Second),
    chromedp.Evaluate(...)
)

There's a small gap between reading outcome and waiting for the flash. If outcome transitions between those two calls (e.g., user action in a parallel run — unlikely in a test, but possible if subtests share state), the flash selector could mismatch. This is extremely unlikely in practice, but combining the two reads into one chromedp.Run batch would make it a non-issue.

4. AsyncOpsState.Status string enum

The valid values ("", "loading", "success", "error") are documented only in a comment on the struct. For an examples repo that pattern-matches readers, adding a small constants block would make the state machine explicit without much overhead:

const (
    asyncStatusIdle    = ""
    asyncStatusLoading = "loading"
    asyncStatusSuccess = "success"
    asyncStatusError   = "error"
)

Not required — the string literals are clear enough given the comment — but worth considering if this pattern gets copy-pasted into production code.

5. go.sum has golang.org/x/sync entries that may still be present

golang.org/x/sync was removed from go.mod in this PR but go.sum typically retains the entry. If go mod tidy was run (the PR says it was), this is expected and harmless. Just noting it in case CI's go mod tidy check is strict.


CLAUDE.md update

The <mark> guideline update is reasonable. One small readability nit: the parenthetical (Rule of thumb: ...) at the end of the <mark> line makes an already-long bullet even longer. Consider splitting it into a sub-bullet or a separate "note" line.


Summary

No blocking issues. The patterns are correct, the goroutine semantics are sound, and the tests prove the key invariants. The observations above are mostly cosmetic or defensive suggestions. Happy to approve once the author has seen them.

- Move <noscript> in lazy-loading.tmpl outside the {{if .Loading}} block.
  The fallback is now unconditional at the top of the article so future
  template restructuring can't accidentally drop it. JS-disabled clients
  always see Loading=true so the rendered output is unchanged in practice;
  this is purely a robustness improvement for future maintenance.
- TestAsyncOperations.Fetch_Transitions: now checks the chromedp.Run error
  when reading `outcome`, and uses a single `flashSelector` variable across
  the WaitFor and Evaluate so the two reads can't drift. The race window
  the bot flagged was theoretical (subtests are sequential) but the
  defensive change is trivially safer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@adnaan
Copy link
Copy Markdown
Contributor Author

adnaan commented Apr 15, 2026

Round 9:

Two consecutive rounds of cosmetic-only items, CI green, PR is functionally complete.

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — Session 3: Loading & Progress (Patterns #14–16)

Overview: Implements three server-push patterns driven by session.TriggerAction from background goroutines. The library dependency bump to v0.8.18 is well-explained and correctly tied to the Session.TriggerAction wiring fix. The go.mod cleanup (~20 stale TUI/migration dependencies) is pure housekeeping with no functional impact.


Controller code (handlers_loading.go)

What's well done:

  • The "check session BEFORE mutating state" ordering (e.g. Start, Fetch, Reload) is the correct defensive pattern — setting Running=true before a nil-session check would leave the UI stuck. The comments explaining why this ordering matters are valuable for readers learning from these examples.
  • The re-entrancy guards (if state.Running, if state.Status == "loading") correctly block duplicate goroutine spawns from direct WS messages that bypass the template-disabled button.
  • All three goroutines use the canonical if err := session.TriggerAction(...); err != nil { return } cancellation pattern consistently.
  • Reconnect semantics are documented thoroughly — the distinction between LazyLoadController needing OnConnect (spinner recovery is the point) vs. ProgressBar/AsyncOps not needing it (no lvt:"persist" tags → fresh state on reconnect) is a non-obvious design decision that deserves exactly this kind of comment.

One thing to revisit — duplicate goroutine spawn in LazyLoadController:

OnConnect and Reload both contain identical goroutine bodies (sleep → TriggerAction → return on error). The payloads differ only in the string prefix:

// OnConnect
"data": "Content loaded lazily at " + time.Now().Format("15:04:05")

// Reload  
"data": "Content reloaded at " + time.Now().Format("15:04:05")

For a demo this is fine, but a reader pattern-matching from this file may conclude that duplication is idiomatic here. If the two goroutines ever diverge (e.g. one adds cancellation context, the other doesn't), the copy will silently miss the update. A tiny private helper — even just spawnLazyLoad(session, prefix string) — would make the intent explicit and the two call sites self-documenting. That said, I recognise CLAUDE.md discourages premature abstractions, so treat this as an optional suggestion.


Templates

lazy-loading.tmpl:

  • The <noscript> block is a thoughtful accessibility affordance. It correctly explains that the spinner will persist with JS disabled rather than silently showing a broken page.

progress-bar.tmpl:

  • The comment explaining why FlashTag must live inside the {{if .Done}} branch (flashes are consumed on first render) is exactly the kind of non-obvious decision that prevents future regressions. Keep this pattern.
  • {{else if not .Running}}not is a Go template built-in and this is correct.

async-operations.tmpl:

  • Using {{with .Result}}/{{with .Error}} instead of {{if eq .Status "success"}} / {{if eq .Status "error"}} is clean: the mutually-exclusive string fields act as natural guards.
  • The comment referencing the CLAUDE.md rule for <mark> justifies the semantic choice inline — good pattern for non-obvious element choices.

CLAUDE.md update

The new <mark> rule is accurate but slightly overlong for a reference card:

Use <mark> for highlighted/badge text and for secondary inline error detail alongside a primary FlashTag/<del> alert. Wrap in aria-live="assertive" when the detail is the sole announcement. (Rule of thumb: <del> is the primary alert; <mark> is a secondary highlight on the same page.)

Minor suggestion: The parenthetical rule-of-thumb restates the sentence before it. Also, "Wrap in aria-live="assertive"" is slightly ambiguous — in the template, the attribute is on the parent <p>, not on <mark> itself. Something like:

Use <mark> for highlighted/badge text, and for secondary inline error detail alongside a primary <del> alert (e.g. <p aria-live="assertive"><mark>{{.Error}}</mark></p>).


Test coverage (patterns_test.go, login_test.go)

What's solid:

  • All three test functions exercise every exported controller method (including Reload, UpdateProgress, FetchResult).
  • No chromedp.Sleep anywhere — all waits use WaitFor/WaitForText with explicit timeouts.
  • TestProgressBar.Start_Runs_To_Completion asserts progress > 0 AND progress < 100 before waiting for completion — this is the right way to prove the goroutine is actually ticking rather than jumping directly to 100.
  • The login regression subtest uses #server-welcome-message (not a bare ins selector) with a comment explaining why. Good defensive selector.

Concurrent_Fetch_Reaches_Single_Result — WS API use is appropriate here:
The test correctly uses window.liveTemplateClient.send() to bypass the template-disabled button and prove the server-side re-entrancy guard works. CLAUDE.md reserves this for "targeted WebSocket protocol tests", which is exactly what this is. The comment explaining the intent is sufficient.

Minor:

  • body[:min(len(body), 800)] requires Go 1.21+ (for the min built-in). This should be fine given the existing go.mod Go version, but worth a quick check if CI runs an older toolchain.
  • TestLazyLoading navigates to the URL in Initial_Load_Shows_Spinner and relies on subsequent subtests reusing the same browser tab. This is the established pattern in this test suite, but it means subtest ordering matters — the subtests are not independent. This is acceptable for sequential UX flow tests (and matches the existing pattern in this file), but worth documenting with a comment if not already present elsewhere.

Summary

This is a well-executed PR. The implementation patterns are correct, the reconnect semantics are thought-through, and the tests are solid. The main actionable items are:

  1. (Optional) Extract the duplicate goroutine body in LazyLoadController into a helper.
  2. (Minor) Tighten the CLAUDE.md <mark> rule wording.
  3. (Non-blocking) Verify min built-in is available in the project's minimum Go version.

The FlashTag placement pattern in progress-bar.tmpl and the session-check-before-mutation ordering in all three controllers are patterns worth preserving as references for future examples.

@adnaan
Copy link
Copy Markdown
Contributor Author

adnaan commented Apr 15, 2026

Round 10: no new actionable items.

Two consecutive rounds of pure-cosmetic / repeat-decline content. PR is converged. Proceeding to merge once `Test All Examples` finishes (currently in-progress, all other 9 checks green).

@adnaan adnaan merged commit 1be1586 into main Apr 15, 2026
10 checks passed
@adnaan adnaan deleted the session-3-loading-progress branch April 15, 2026 04:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants