Progress Bar
A background goroutine ticks progress from 10% to 100% via WebSocket pushes — no polling.
Show real server-side progress without polling. Start sets Running and spawns a
goroutine that pushes the percentage every 500ms with
session.TriggerAction("updateProgress", …), climbing 10% at a time until the
UpdateProgress action hits 100%, flips to Done, and emits a success flash.
Progress and Done are persisted so a finished run survives a brief reconnect, while
Running is intentionally not — a stale spinner with no goroutine behind it is the
failure mode to avoid.
A native <progress> element bound to .Progress, shown whenever the job is running or
done. The success FlashTag lives inside the {{if .Done}} branch so it renders in the
same pass that completes the job, and the button label switches between Start Job and
Run Again.
{{define "content"}}
<article>
<h3>Progress Bar</h3>
<p><small>A background goroutine ticks progress from 10% to 100% via WebSocket pushes — no polling.</small></p>
{{if or .Running .Done}}
<progress value="{{.Progress}}" max="100"></progress>
<p><small>{{.Progress}}% complete</small></p>
{{end}}
{{if .Done}}
{{/* FlashTag must live inside the .Done branch: UpdateProgress emits
the flash only when transitioning to Done, so they always render
together. Flashes are ephemeral (consumed on first render) — moving
this outside the branch would cause the flash to be consumed during
a Running or idle render before Done is reached, and the user would
never see it. */}}
{{.lvt.FlashTag "success"}}
<form method="POST">
<button name="start">Run Again</button>
</form>
{{else if not .Running}}
<form method="POST">
<button name="start">Start Job</button>
</form>
{{end}}
</article>
{{end}}
Start guards against re-entrancy, then spawnTicker runs the bounded loop;
tickWithRetry retries each push for ~5s so a brief mobile background doesn't drop a
tick. UpdateProgress writes the value and finalizes at 100%.
// ProgressBarController drives a bounded goroutine that ticks progress from
// 10% to 100% in 10% increments every 500ms. session.TriggerAction is
// retried for ~5 seconds per tick when the session group has zero
// connections, so brief mobile backgrounding (iOS app-switch under the
// client's 3s visibility-reconnect threshold) doesn't lose ticks. The
// retry budget is per-tick — a tick that never succeeds blocks for ~5s,
// so the goroutine's worst-case lifetime under a permanent disconnect is
// (progressTickRate + progressRetryWindow) × ceil((100-Progress)/progressStep),
// bounded at ~55s for the full 10-tick run. The next Mount returns non-Running
// state (Running is intentionally not persisted) and the user sees a
// clean Start Job button.
//
// No Mount-driven revival: a second goroutine spawned by Mount while the
// retrying goroutine was still alive caused racing UpdateProgress writes
// (one goroutine sets Done=true, the trailing one overwrites Progress
// with a mid-flight value, producing impossible "Run Again at 70%" UI).
// Likewise no OnConnect: the framework's restorePersistedState already
// loads Progress/Done from the session-group store on every reconnect,
// so manual hydration would be redundant and would re-introduce the
// same race window.
//
// UpdateProgress also guards on state.Done as defense in depth.
type ProgressBarController struct{}
// Retry attempt count derives from window/delay so the ~5s total stays
// consistent if either is tuned later. Both Duration operands are
// constants and Go allows int(typedConst), so the whole trio remains
// const — keeps the values immutable from test code.
const (
progressStep = 10
progressTickRate = 500 * time.Millisecond
progressRetryDelay = 100 * time.Millisecond
progressRetryWindow = 5 * time.Second
progressRetryAttempts = int(progressRetryWindow / progressRetryDelay)
)
func (c *ProgressBarController) Start(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) {
if state.Running {
return state, nil
}
session := ctx.Session()
if session == nil {
return state, nil
}
state.Running = true
state.Done = false
state.Progress = 0
c.spawnTicker(session)
return state, nil
}
func (c *ProgressBarController) UpdateProgress(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) {
// Guard against stale ticks from a goroutine that was overtaken by a
// faster one (e.g. multi-tab race). Without this, a trailing goroutine
// could overwrite Progress to a mid-flight value AFTER another goroutine
// already set Done=true, producing an impossible "Run Again at 70%" UI.
if state.Done {
return state, nil
}
state.Progress = ctx.GetInt("progress")
if state.Progress >= 100 {
state.Running = false
state.Done = true
ctx.SetFlash("success", "Job complete", livetemplate.FlashExpiry(flashSuccessExpiry))
nudgeFlashExpiry(ctx, flashSuccessExpiry)
}
return state, nil
}
func (c *ProgressBarController) Refresh(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) {
return state, nil
}
// spawnTicker drives state.Progress 10..100. tickWithRetry survives a
// ~5s window of dead WebSocket so brief mobile backgrounds don't end
// the run; if the connection comes back within that window the timer
// resumes seamlessly.
func (c *ProgressBarController) spawnTicker(session livetemplate.Session) {
go func() {
for i := progressStep; i <= 100; i += progressStep {
time.Sleep(progressTickRate)
if err := tickWithRetry(session, i); err != nil {
return
}
}
}()
}
func tickWithRetry(session livetemplate.Session, progress int) error {
var lastErr error
for attempt := 0; attempt < progressRetryAttempts; attempt++ {
if attempt > 0 {
time.Sleep(progressRetryDelay)
}
err := session.TriggerAction("updateProgress", map[string]any{
"progress": progress,
})
if err == nil {
return nil
}
lastErr = err
}
return lastErr
}
func progressBarHandler() http.Handler {
tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/loading/progress-bar.tmpl")
return tmpl.Handle(&ProgressBarController{}, livetemplate.AsState(&ProgressBarState{
Title: "Progress Bar",
Category: "Loading & Progress",
}))
}
// ProgressBarState holds the state for the Progress Bar pattern (#15).
//
// Progress and Done are persisted so a completed run's outcome
// survives a brief reconnect (e.g. mobile app-switching). Running is
// NOT persisted: a stale Running=true with no goroutine to advance it
// would leave the UI on aria-busy forever, the failure mode Pattern
// #31 explicitly avoids. The ticker goroutine retries TriggerAction
// for a bounded window before exiting, so brief backgrounds (<~5s)
// are recovered by the goroutine itself; longer disconnects exit and
// the user sees a clean "Start Job" button via the persisted but
// non-Running state.
type ProgressBarState struct {
Title string
Category string
Progress int `lvt:"persist"`
Running bool
Done bool `lvt:"persist"`
}
Reach for Async Operations when the work has no measurable progress and you only need loading / success / error, or Lazy Loading when content just needs to arrive after the first paint.