Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/livetemplate/components v0.0.0-20251224004709-1f8c1de230b4
github.com/livetemplate/livetemplate v0.8.0
github.com/livetemplate/livetemplate v0.8.1-0.20260118195628-f6a04e2a2d8b
github.com/livetemplate/lvt v0.0.0-20260110064539-b9afb9e6df26
modernc.org/sqlite v1.43.0
)
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/livetemplate/components v0.0.0-20251224004709-1f8c1de230b4 h1:wLfVleSSlcv4NPg5KN8pul0Rz9ub1CtI8OAcPlyBYlw=
github.com/livetemplate/components v0.0.0-20251224004709-1f8c1de230b4/go.mod h1:+C2iGZfdgjc6y6MsaDHBWzWGIbBHna4l+ygFYJfuyUo=
github.com/livetemplate/livetemplate v0.8.0 h1:eWUhB6jwkTj4rfgRAZG+Eap0wGeaovmwXO3hGdQpf5M=
github.com/livetemplate/livetemplate v0.8.0/go.mod h1:0jD5ccG/VQ/BmjbsZdOamAeFh+aO/f1yJeMQqhxPa68=
github.com/livetemplate/livetemplate v0.8.1-0.20260118195628-f6a04e2a2d8b h1:jzwam9JEm/IQpwCNHLeKD33004pjmQDdbkpmLeL/s4U=
github.com/livetemplate/livetemplate v0.8.1-0.20260118195628-f6a04e2a2d8b/go.mod h1:0jD5ccG/VQ/BmjbsZdOamAeFh+aO/f1yJeMQqhxPa68=
github.com/livetemplate/lvt v0.0.0-20260110064539-b9afb9e6df26 h1:RRYko8rFvHz8ad5ixw3ke1ZvJKMtVqJA6Ddxx92Wobw=
github.com/livetemplate/lvt v0.0.0-20260110064539-b9afb9e6df26/go.mod h1:b+qhiaDS5oURHjiCs+ZPOoJTtZ+5cCM8xyriVA97uQo=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
Expand Down
91 changes: 91 additions & 0 deletions progressive-enhancement/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Progressive Enhancement Example

This example demonstrates how LiveTemplate supports progressive enhancement - allowing apps to work both with and without JavaScript enabled.

## How It Works

### With JavaScript (WebSocket Mode)
- Actions are sent via WebSocket for instant updates
- No page reloads - UI updates in real-time
- Best user experience for modern browsers

### Without JavaScript (HTTP Form Mode)
- Actions are submitted via standard HTML forms
- Server returns full HTML pages using POST-Redirect-GET pattern
- Page reloads after each action
- Works on any browser, including text-based browsers

## Key Concepts

### Dual-Mode Forms

Forms work in both modes by including both `lvt-submit` (for JS) and `method="POST"` + `lvt-action` (for no-JS):

```html
<form method="POST" lvt-submit="add">
<input type="hidden" name="lvt-action" value="add">
<input type="text" name="title">
<button type="submit">Add</button>
</form>
```

- **With JS**: `lvt-submit` triggers a WebSocket action, preventing form submission
- **Without JS**: Standard form submission sends POST request to server

### POST-Redirect-GET (PRG) Pattern

For non-JS clients, successful actions redirect using HTTP 303:

1. User submits form via POST
2. Server processes action, updates state
3. Server responds with 303 redirect to same URL
4. Browser follows redirect with GET
5. User sees updated page

This prevents duplicate submissions when users refresh the page.

### Validation Errors

When validation fails:
- **With JS**: Errors appear instantly via WebSocket update
- **Without JS**: Server re-renders the page with errors inline (no redirect)

### Flash Messages

Success/error messages are shown once after actions:
- **With JS**: Messages appear in real-time
- **Without JS**: Messages passed via query params after redirect

## Running the Example

```bash
# Development mode (uses local client library)
LVT_DEV_MODE=true go run .

# Production mode (uses CDN client library)
go run .
```

Visit http://localhost:8080 and try:
1. With JavaScript enabled - notice instant updates
2. Disable JavaScript and refresh - notice page reloads after each action
3. Both modes provide the same functionality

## Configuration

Progressive enhancement is enabled by default. To disable it:

```go
// Via environment variable
LVT_PROGRESSIVE_ENHANCEMENT=false go run .

// Or via code
tmpl := livetemplate.New("app", livetemplate.WithProgressiveEnhancement(false))
```

## Files

- `main.go` - Controller, state, and action handlers
- `progressive-enhancement.tmpl` - Template with dual-mode forms
- `progressive_enhancement_test.go` - End-to-end tests for progressive enhancement behavior
- `README.md` - This documentation
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

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

The Files section lists only three files but omits the test file. Consider adding progressive_enhancement_test.go to the list since it's part of the example and demonstrates E2E testing patterns.

Suggested change
- `README.md` - This documentation
- `README.md` - This documentation
- `progressive_enhancement_test.go` - End-to-end tests for progressive enhancement behavior

Copilot uses AI. Check for mistakes.
186 changes: 186 additions & 0 deletions progressive-enhancement/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package main

import (
"fmt"
"log"
"net/http"
"os"
"strings"
"time"

"github.com/go-playground/validator/v10"
"github.com/livetemplate/livetemplate"
e2etest "github.com/livetemplate/lvt/testing"
)

// TodoController is a singleton that holds dependencies.
// In this example, we don't have external dependencies like a database.
type TodoController struct {
validate *validator.Validate
}

// TodoState is pure data, cloned per session.
// It contains all the state needed to render the template.
type TodoState struct {
Title string `json:"title"`
Items []Todo `json:"items"`
// Form input values (preserved on validation errors)
InputTitle string `json:"input_title"`
}

// Todo represents a single todo item.
type Todo struct {
ID string `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
CreatedAt string `json:"created_at"`
}

// AddInput is the input struct for the Add action.
type AddInput struct {
Title string `json:"title" validate:"required,min=3,max=100"`
}

// Mount is called once when a session is created.
func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state.Title = "Progressive Enhancement Todo List"

// Pre-populate with sample items if empty
if len(state.Items) == 0 {
state.Items = []Todo{
{ID: "1", Title: "Learn about progressive enhancement", Completed: true, CreatedAt: formatTime()},
{ID: "2", Title: "Try the app without JavaScript", Completed: false, CreatedAt: formatTime()},
{ID: "3", Title: "Enable JavaScript and see the difference", Completed: false, CreatedAt: formatTime()},
}
}

// Check for flash messages from URL (after redirect)
if success := ctx.GetString("success"); success != "" {
ctx.SetFlash("success", success)
}
if errorMsg := ctx.GetString("error"); errorMsg != "" {
ctx.SetFlash("error", errorMsg)
}

return state, nil
}

// Add handles adding a new todo item.
func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input AddInput
if err := ctx.BindAndValidate(&input, c.validate); err != nil {
// Preserve the input value so user doesn't have to retype
state.InputTitle = ctx.GetString("title")
return state, err
}

// Create new todo
newID := fmt.Sprintf("%d", time.Now().UnixNano())
state.Items = append(state.Items, Todo{
ID: newID,
Title: strings.TrimSpace(input.Title),
Completed: false,
CreatedAt: formatTime(),
})

// Clear the input field
state.InputTitle = ""

// Set flash message for success
ctx.SetFlash("success", fmt.Sprintf("Added: %s", input.Title))

return state, nil
}

// Toggle handles toggling a todo's completed status.
func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
id := ctx.GetString("id")
found := false
for i := range state.Items {
if state.Items[i].ID == id {
state.Items[i].Completed = !state.Items[i].Completed
found = true
ctx.SetFlash("success", "Item updated")
break
}
}
if !found {
ctx.SetFlash("error", "Item not found")
}
return state, nil
}

// Delete handles deleting a todo item.
func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
id := ctx.GetString("id")

// Find index of the item to delete without modifying the slice during iteration.
deleteIndex := -1
for i, item := range state.Items {
if item.ID == id {
deleteIndex = i
break
}
}

if deleteIndex >= 0 {
state.Items = append(state.Items[:deleteIndex], state.Items[deleteIndex+1:]...)
ctx.SetFlash("success", "Item deleted")
} else {
ctx.SetFlash("error", "Item not found")
}
return state, nil
}

func formatTime() string {
return time.Now().Format("2006-01-02 15:04:05")
}

func main() {
log.Println("Progressive Enhancement Example starting...")

// Load configuration from environment variables
envConfig, err := livetemplate.LoadEnvConfig()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}

// Validate configuration
if err := envConfig.Validate(); err != nil {
log.Fatalf("Invalid configuration: %v", err)
}

// Create controller with validator
controller := &TodoController{
validate: validator.New(),
}

// Create initial state
initialState := &TodoState{}

// Create template with configuration
// Progressive enhancement is enabled by default
tmpl := livetemplate.Must(livetemplate.New("progressive-enhancement", envConfig.ToOptions()...))

// Mount handler
http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState)))

// Serve client library (development only)
http.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary)

port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Server starting on http://localhost:%s", port)
log.Println("")
log.Println("Try it:")
log.Println(" 1. Open in browser with JavaScript ENABLED - uses WebSocket for instant updates")
log.Println(" 2. Disable JavaScript and refresh - uses HTTP form submissions with page reloads")
log.Println(" 3. Both modes work identically, just with different performance characteristics")
log.Println("")

if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
Loading
Loading