diff --git a/README.md b/README.md
index 2f77311..c7215dd 100644
--- a/README.md
+++ b/README.md
@@ -21,8 +21,6 @@ All examples follow the [progressive complexity](https://github.com/livetemplate
| `live-preview/` | 1 | Change() live updates | None |
| `login/` | 1 | Authentication + sessions | None |
| `shared-notepad/` | 1 | BasicAuth + SharedState | None |
-| `ephemeral-counter/` | 1 | Ephemeral state with in-memory DB | None |
-| `ephemeral-todos/` | 1 | Ephemeral state with SQLite DB | None |
## Examples
diff --git a/avatar-upload/main.go b/avatar-upload/main.go
index 19a8847..1ee1cc4 100644
--- a/avatar-upload/main.go
+++ b/avatar-upload/main.go
@@ -20,10 +20,10 @@ type ProfileController struct{}
// ProfileState is pure data, cloned per session.
type ProfileState struct {
- Name string
- Email string
- AvatarPath string
- AvatarURL string
+ Name string `lvt:"persist"`
+ Email string `lvt:"persist"`
+ AvatarPath string `lvt:"persist"`
+ AvatarURL string `lvt:"persist"`
}
// UpdateProfile handles the "UpdateProfile" action for profile update form submission
diff --git a/chat/main.go b/chat/main.go
index 25c69b3..151c2e9 100644
--- a/chat/main.go
+++ b/chat/main.go
@@ -25,7 +25,7 @@ type ChatController struct {
// CurrentUser is never shared across tabs.
type ChatState struct {
Messages []Message `json:"messages"`
- CurrentUser string `json:"current_user"`
+ CurrentUser string `json:"current_user" lvt:"persist"`
OnlineCount int `json:"online_count"`
TotalMessages int `json:"total_messages"`
}
diff --git a/counter/main.go b/counter/main.go
index 6dc1589..292179f 100644
--- a/counter/main.go
+++ b/counter/main.go
@@ -19,9 +19,9 @@ type CounterController struct{}
// CounterState is pure data, cloned per session.
type CounterState struct {
- Title string `json:"title"`
- Counter int `json:"counter"`
- LastUpdated string `json:"last_updated"`
+ Title string `json:"title" lvt:"persist"`
+ Counter int `json:"counter" lvt:"persist"`
+ LastUpdated string `json:"last_updated" lvt:"persist"`
}
func (c *CounterController) Increment(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
diff --git a/ephemeral-counter/counter.tmpl b/ephemeral-counter/counter.tmpl
deleted file mode 100644
index 7796507..0000000
--- a/ephemeral-counter/counter.tmpl
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
- Ephemeral Counter
-
-
-
-
-
-
-
-
- Count: {{.Count}}
-
-
-
-
-
-
-
-
- {{if .lvt.DevMode}}
-
- {{else}}
-
- {{end}}
-
-
diff --git a/ephemeral-counter/counter_test.go b/ephemeral-counter/counter_test.go
deleted file mode 100644
index b582133..0000000
--- a/ephemeral-counter/counter_test.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
- "testing"
- "time"
-
- "github.com/chromedp/chromedp"
- e2etest "github.com/livetemplate/lvt/testing"
-)
-
-func TestMain(m *testing.M) {
- e2etest.CleanupChromeContainers()
- code := m.Run()
- e2etest.CleanupChromeContainers()
- os.Exit(code)
-}
-
-func TestEphemeralCounterE2E(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping E2E test in short mode")
- }
-
- serverPort, err := e2etest.GetFreePort()
- if err != nil {
- t.Fatalf("Failed to get free port for server: %v", err)
- }
-
- debugPort, err := e2etest.GetFreePort()
- if err != nil {
- t.Fatalf("Failed to get free port for Chrome: %v", err)
- }
-
- serverCmd := e2etest.StartTestServer(t, "main.go", serverPort)
- defer func() {
- if serverCmd != nil && serverCmd.Process != nil {
- serverCmd.Process.Kill()
- }
- }()
-
- chromeCmd := e2etest.StartDockerChrome(t, debugPort)
- defer e2etest.StopDockerChrome(t, debugPort)
- _ = chromeCmd
-
- chromeURL := fmt.Sprintf("http://localhost:%d", debugPort)
- allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL)
- defer allocCancel()
-
- ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf))
- defer cancel()
-
- ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
- defer cancel()
-
- t.Run("Initial_Load", func(t *testing.T) {
- var html string
- err := chromedp.Run(ctx,
- chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
- e2etest.WaitForWebSocketReady(5*time.Second),
- chromedp.WaitVisible(`h1`, chromedp.ByQuery),
- e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"),
- chromedp.OuterHTML(`body`, &html, chromedp.ByQuery),
- )
- if err != nil {
- t.Fatalf("Failed to load page: %v", err)
- }
- if !strings.Contains(html, "Count: 0") {
- t.Error("Expected initial Count: 0")
- }
- })
-
- t.Run("Increment", func(t *testing.T) {
- err := chromedp.Run(ctx,
- e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 5*time.Second),
- chromedp.Evaluate(`document.querySelector('button[name="increment"]').click()`, nil),
- e2etest.WaitFor(`document.body.innerText.includes('Count: 1')`, 5*time.Second),
- )
- if err != nil {
- t.Fatalf("Failed to increment: %v", err)
- }
- })
-
- t.Run("Ephemeral_State_Survives_Refresh_Via_DB", func(t *testing.T) {
- // Ephemeral mode: session state is NOT persisted, but the in-memory "DB"
- // retains the count. On refresh, Mount() reloads from DB, so count = 1.
- err := chromedp.Run(ctx,
- chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
- e2etest.WaitForWebSocketReady(5*time.Second),
- chromedp.WaitVisible(`h1`, chromedp.ByQuery),
- )
- if err != nil {
- t.Fatalf("Failed to reload: %v", err)
- }
-
- var html string
- if err := chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)); err != nil {
- t.Fatalf("Failed to get HTML: %v", err)
- }
-
- if !strings.Contains(html, "Count: 1") {
- t.Errorf("Expected Count: 1 after refresh (loaded from DB), got: %s", html)
- }
- })
-}
diff --git a/ephemeral-counter/main.go b/ephemeral-counter/main.go
deleted file mode 100644
index 65c022c..0000000
--- a/ephemeral-counter/main.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package main
-
-import (
- "context"
- "log/slog"
- "net/http"
- "os"
- "os/signal"
- "sync"
- "syscall"
- "time"
-
- "github.com/livetemplate/livetemplate"
- e2etest "github.com/livetemplate/lvt/testing"
-)
-
-// CounterDB is a thread-safe in-memory counter that acts as the "database".
-// With WithEphemeralState(), LiveTemplate state is rebuilt from this store
-// on every request — no session persistence needed.
-type CounterDB struct {
- mu sync.Mutex
- value int
-}
-
-func (db *CounterDB) Get() int {
- db.mu.Lock()
- defer db.mu.Unlock()
- return db.value
-}
-
-func (db *CounterDB) Add(delta int) int {
- db.mu.Lock()
- defer db.mu.Unlock()
- db.value += delta
- return db.value
-}
-
-func (db *CounterDB) Reset() {
- db.mu.Lock()
- defer db.mu.Unlock()
- db.value = 0
-}
-
-type CounterController struct {
- DB *CounterDB
-}
-
-type CounterState struct {
- Count int `json:"count"`
-}
-
-func (c *CounterController) Mount(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
- state.Count = c.DB.Get()
- return state, nil
-}
-
-func (c *CounterController) Increment(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
- state.Count = c.DB.Add(1)
- return state, nil
-}
-
-func (c *CounterController) Decrement(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
- state.Count = c.DB.Add(-1)
- return state, nil
-}
-
-func (c *CounterController) Reset(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
- c.DB.Reset()
- state.Count = 0
- return state, nil
-}
-
-func main() {
- controller := &CounterController{DB: &CounterDB{}}
-
- tmpl := livetemplate.Must(livetemplate.New("counter"))
- handler := tmpl.Handle(controller, livetemplate.AsState(&CounterState{}),
- livetemplate.WithEphemeralState(),
- )
-
- mux := http.NewServeMux()
- mux.Handle("/", handler)
- mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary)
-
- port := os.Getenv("PORT")
- if port == "" {
- port = "8080"
- }
-
- server := &http.Server{
- Addr: ":" + port,
- Handler: mux,
- ReadTimeout: 15 * time.Second,
- WriteTimeout: 15 * time.Second,
- }
-
- quit := make(chan os.Signal, 1)
- signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
-
- go func() {
- slog.Info("Server starting", "url", "http://localhost:"+port)
- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- slog.Error("Server failed", "error", err)
- os.Exit(1)
- }
- }()
-
- <-quit
-
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- if err := server.Shutdown(ctx); err != nil {
- slog.Error("Shutdown error", "error", err)
- }
-}
diff --git a/ephemeral-todos/main.go b/ephemeral-todos/main.go
deleted file mode 100644
index 0d7e559..0000000
--- a/ephemeral-todos/main.go
+++ /dev/null
@@ -1,167 +0,0 @@
-package main
-
-import (
- "context"
- "database/sql"
- "log/slog"
- "net/http"
- "os"
- "os/signal"
- "strconv"
- "syscall"
- "time"
-
- "github.com/livetemplate/livetemplate"
- e2etest "github.com/livetemplate/lvt/testing"
- _ "modernc.org/sqlite"
-)
-
-type Todo struct {
- ID int `json:"id"`
- Title string `json:"title"`
- Done bool `json:"done"`
-}
-
-// TodoController holds the database dependency.
-type TodoController struct {
- DB *sql.DB
-}
-
-// TodoState is rebuilt from the database on every request.
-type TodoState struct {
- Items []Todo `json:"items"`
-}
-
-func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
- rows, err := c.DB.Query("SELECT id, title, done FROM todos ORDER BY id")
- if err != nil {
- return state, err
- }
- defer rows.Close()
-
- state.Items = nil
- for rows.Next() {
- var t Todo
- if err := rows.Scan(&t.ID, &t.Title, &t.Done); err != nil {
- return state, err
- }
- state.Items = append(state.Items, t)
- }
- return state, nil
-}
-
-func (c *TodoController) Submit(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
- title := ctx.GetString("title")
- if title == "" {
- return state, livetemplate.FieldError{Field: "title", Message: "Title is required"}
- }
-
- result, err := c.DB.Exec("INSERT INTO todos (title, done) VALUES (?, 0)", title)
- if err != nil {
- return state, err
- }
-
- id, _ := result.LastInsertId()
- state.Items = append(state.Items, Todo{ID: int(id), Title: title, Done: false})
- return state, nil
-}
-
-func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
- id, _ := strconv.Atoi(ctx.GetString("value"))
- _, err := c.DB.Exec("UPDATE todos SET done = NOT done WHERE id = ?", id)
- if err != nil {
- return state, err
- }
-
- for i := range state.Items {
- if state.Items[i].ID == id {
- state.Items[i].Done = !state.Items[i].Done
- break
- }
- }
- return state, nil
-}
-
-func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
- id, _ := strconv.Atoi(ctx.GetString("value"))
- _, err := c.DB.Exec("DELETE FROM todos WHERE id = ?", id)
- if err != nil {
- return state, err
- }
-
- for i := range state.Items {
- if state.Items[i].ID == id {
- state.Items = append(state.Items[:i], state.Items[i+1:]...)
- break
- }
- }
- return state, nil
-}
-
-func initDB() *sql.DB {
- db, err := sql.Open("sqlite", "file:todos.db?cache=shared&mode=rwc")
- if err != nil {
- slog.Error("Failed to open database", "error", err)
- os.Exit(1)
- }
- _, err = db.Exec(`CREATE TABLE IF NOT EXISTS todos (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- title TEXT NOT NULL,
- done BOOLEAN NOT NULL DEFAULT 0
- )`)
- if err != nil {
- slog.Error("Failed to create table", "error", err)
- os.Exit(1)
- }
- return db
-}
-
-func main() {
- db := initDB()
- defer db.Close()
-
- controller := &TodoController{DB: db}
-
- opts := []livetemplate.Option{}
- if os.Getenv("LVT_WEBSOCKET_DISABLED") == "true" {
- opts = append(opts, livetemplate.WithWebSocketDisabled())
- }
-
- tmpl := livetemplate.Must(livetemplate.New("todos", opts...))
- handler := tmpl.Handle(controller, livetemplate.AsState(&TodoState{}),
- livetemplate.WithEphemeralState(),
- )
-
- mux := http.NewServeMux()
- mux.Handle("/", handler)
- mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary)
-
- port := os.Getenv("PORT")
- if port == "" {
- port = "8080"
- }
-
- server := &http.Server{
- Addr: ":" + port,
- Handler: mux,
- ReadTimeout: 15 * time.Second,
- WriteTimeout: 15 * time.Second,
- }
-
- quit := make(chan os.Signal, 1)
- signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
-
- go func() {
- slog.Info("Server starting", "url", "http://localhost:"+port)
- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- slog.Error("Server failed", "error", err)
- os.Exit(1)
- }
- }()
-
- <-quit
-
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- server.Shutdown(ctx)
-}
diff --git a/ephemeral-todos/todos.tmpl b/ephemeral-todos/todos.tmpl
deleted file mode 100644
index 49329a9..0000000
--- a/ephemeral-todos/todos.tmpl
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
- Ephemeral Todos
-
-
-
-
-
-
-
-
-
-
-
- {{if .Items}}
-
- {{else}}
- No todos yet. Add one above!
- {{end}}
-
-
-
- {{if .lvt.DevMode}}
-
- {{else}}
-
- {{end}}
-
-
diff --git a/ephemeral-todos/todos_test.go b/ephemeral-todos/todos_test.go
deleted file mode 100644
index e36efe3..0000000
--- a/ephemeral-todos/todos_test.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
- "testing"
- "time"
-
- "github.com/chromedp/chromedp"
- e2etest "github.com/livetemplate/lvt/testing"
-)
-
-func TestMain(m *testing.M) {
- e2etest.CleanupChromeContainers()
- code := m.Run()
- e2etest.CleanupChromeContainers()
- os.Exit(code)
-}
-
-func TestEphemeralTodosE2E(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping E2E test in short mode")
- }
-
- serverPort, err := e2etest.GetFreePort()
- if err != nil {
- t.Fatalf("Failed to get free port for server: %v", err)
- }
-
- debugPort, err := e2etest.GetFreePort()
- if err != nil {
- t.Fatalf("Failed to get free port for Chrome: %v", err)
- }
-
- serverCmd := e2etest.StartTestServer(t, "main.go", serverPort)
- defer func() {
- if serverCmd != nil && serverCmd.Process != nil {
- serverCmd.Process.Kill()
- }
- }()
-
- chromeCmd := e2etest.StartDockerChrome(t, debugPort)
- defer e2etest.StopDockerChrome(t, debugPort)
- _ = chromeCmd
-
- chromeURL := fmt.Sprintf("http://localhost:%d", debugPort)
- allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL)
- defer allocCancel()
-
- ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf))
- defer cancel()
-
- ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
- defer cancel()
-
- t.Run("Initial_Load_Empty", func(t *testing.T) {
- var html string
- err := chromedp.Run(ctx,
- chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
- e2etest.WaitForWebSocketReady(5*time.Second),
- chromedp.WaitVisible(`h1`, chromedp.ByQuery),
- e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"),
- chromedp.OuterHTML(`body`, &html, chromedp.ByQuery),
- )
- if err != nil {
- t.Fatalf("Failed to load page: %v", err)
- }
- if !strings.Contains(html, "No todos yet") {
- t.Error("Expected empty state message")
- }
- })
-
- t.Run("Add_Todo", func(t *testing.T) {
- err := chromedp.Run(ctx,
- e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 5*time.Second),
- chromedp.SetValue(`input[name="title"]`, "Buy groceries", chromedp.ByQuery),
- chromedp.Evaluate(`document.querySelector('button[type="submit"]').click()`, nil),
- e2etest.WaitFor(`document.body.innerText.includes('Buy groceries')`, 5*time.Second),
- )
- if err != nil {
- t.Fatalf("Failed to add todo: %v", err)
- }
- })
-
- t.Run("Todo_Survives_Refresh_Via_DB", func(t *testing.T) {
- // Ephemeral mode: session state is NOT persisted, but SQLite retains the
- // todo. On refresh, Mount() reloads from DB.
- err := chromedp.Run(ctx,
- chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
- e2etest.WaitForWebSocketReady(5*time.Second),
- chromedp.WaitVisible(`h1`, chromedp.ByQuery),
- )
- if err != nil {
- t.Fatalf("Failed to reload: %v", err)
- }
-
- var html string
- if err := chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)); err != nil {
- t.Fatalf("Failed to get HTML: %v", err)
- }
-
- if !strings.Contains(html, "Buy groceries") {
- t.Errorf("Expected 'Buy groceries' after refresh (loaded from DB), got: %s", html)
- }
- })
-}
diff --git a/flash-messages/main.go b/flash-messages/main.go
index f399231..d0e4a8c 100644
--- a/flash-messages/main.go
+++ b/flash-messages/main.go
@@ -21,8 +21,8 @@ type FlashController struct{}
// FlashState holds the demo data.
type FlashState struct {
Title string `json:"title"`
- Items []string `json:"items"`
- ItemCount int `json:"item_count"`
+ Items []string `json:"items" lvt:"persist"`
+ ItemCount int `json:"item_count" lvt:"persist"`
}
// AddItem handles the "add_item" action - demonstrates success flash.
diff --git a/go.mod b/go.mod
index 210b8f4..d8f1f1b 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
- github.com/livetemplate/livetemplate v0.8.11
+ github.com/livetemplate/livetemplate v0.8.12
github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e
github.com/livetemplate/lvt/components v0.0.0-20260327182801-53d6d40e692e
modernc.org/sqlite v1.43.0
diff --git a/go.sum b/go.sum
index 320bcde..47cc436 100644
--- a/go.sum
+++ b/go.sum
@@ -96,6 +96,10 @@ github.com/livetemplate/livetemplate v0.8.10 h1:Lg62gb297Iq3/EShPnEUyqDFN8aeyTYc
github.com/livetemplate/livetemplate v0.8.10/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
github.com/livetemplate/livetemplate v0.8.11 h1:s7yFfHp53tv5W7WNViQUK7EBq6ZmBth+mGA34+/zL9s=
github.com/livetemplate/livetemplate v0.8.11/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
+github.com/livetemplate/livetemplate v0.8.12-0.20260401033655-c6263289f218 h1:qOcImuTin52xJefrRBm/vJfOaYrRYG+GnU1gB6/47aw=
+github.com/livetemplate/livetemplate v0.8.12-0.20260401033655-c6263289f218/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
+github.com/livetemplate/livetemplate v0.8.12 h1:OeGWVFgLzFOUBGnTFSGSlJKAgIZVyuEjlaWYoBT8iqI=
+github.com/livetemplate/livetemplate v0.8.12/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e h1:nAV7BaOatFcbSaP6m9CgLrRsGPeWtbjPDZ8dOI6Zb+c=
github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e/go.mod h1:17cFl500ntymD3gx8h+ZODnVnTictHgG8Wmz/By75sU=
github.com/livetemplate/lvt/components v0.0.0-20260327182801-53d6d40e692e h1:vuR0pQtEQHZOD2/HvTJfHPKEdoD77XUs1mq1kdjgVig=
diff --git a/login/main.go b/login/main.go
index 8c1decf..b288941 100644
--- a/login/main.go
+++ b/login/main.go
@@ -23,11 +23,11 @@ type AuthController struct {
// AuthState is pure data, cloned per session.
// Contains only serializable fields for the auth UI.
type AuthState struct {
- Username string
- IsLoggedIn bool
+ Username string `lvt:"persist"`
+ IsLoggedIn bool `lvt:"persist"`
Error string
- ServerMessage string // Message sent from server via WebSocket
- LoginTime time.Time // When user logged in
+ ServerMessage string
+ LoginTime time.Time `lvt:"persist"`
}
// Login handles the "login" action
diff --git a/profile-progressive/main.go b/profile-progressive/main.go
index ea0097a..a9f1af9 100644
--- a/profile-progressive/main.go
+++ b/profile-progressive/main.go
@@ -20,10 +20,10 @@ import (
var validate = validator.New()
type ProfileState struct {
- DisplayName string
- Email string
- Bio string
- Saved bool
+ DisplayName string `lvt:"persist"`
+ Email string `lvt:"persist"`
+ Bio string `lvt:"persist"`
+ Saved bool `lvt:"persist"`
}
type ProfileController struct {
diff --git a/progressive-enhancement/main.go b/progressive-enhancement/main.go
index cedab08..6285c39 100644
--- a/progressive-enhancement/main.go
+++ b/progressive-enhancement/main.go
@@ -23,9 +23,9 @@ type TodoController struct {
// It contains all the state needed to render the template.
type TodoState struct {
Title string `json:"title"`
- Items []Todo `json:"items"`
+ Items []Todo `json:"items" lvt:"persist"`
// Form input values (preserved on validation errors)
- InputTitle string `json:"input_title"`
+ InputTitle string `json:"input_title" lvt:"persist"`
}
// Todo represents a single todo item.
diff --git a/shared-notepad/main.go b/shared-notepad/main.go
index 26ab79b..b20d047 100644
--- a/shared-notepad/main.go
+++ b/shared-notepad/main.go
@@ -18,9 +18,9 @@ type NotepadController struct {
type NotepadState struct {
Username string `json:"username"`
- Content string `json:"content"`
- SavedAt string `json:"saved_at"`
- CharCount int `json:"char_count"`
+ Content string `json:"content" lvt:"persist"`
+ SavedAt string `json:"saved_at" lvt:"persist"`
+ CharCount int `json:"char_count" lvt:"persist"`
}
func (c *NotepadController) Mount(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) {
diff --git a/test-all.sh b/test-all.sh
index 33a3a9d..ae85c0d 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -36,8 +36,6 @@ WORKING_EXAMPLES=(
"todos-components"
"shared-notepad"
"flash-messages"
- "ephemeral-counter"
- "ephemeral-todos"
)
# Disabled examples
diff --git a/todos-components/main.go b/todos-components/main.go
index 22ded49..9f7f393 100644
--- a/todos-components/main.go
+++ b/todos-components/main.go
@@ -23,18 +23,40 @@ type Todo struct {
// TodoState holds the application state.
type TodoState struct {
- Title string
- Todos []Todo
- NewTodoTitle string
+ Title string `lvt:"persist"`
+ Todos []Todo `lvt:"persist"`
+ NewTodoTitle string `lvt:"persist"`
Toasts *toast.Container
DeleteConfirm *modal.ConfirmModal
- DeleteID int // ID of todo pending deletion
- NextID int // Must be exported for JSON serialization
+ DeleteID int // ID of todo pending deletion
+ NextID int `lvt:"persist"`
}
// TodoController handles todo actions.
type TodoController struct{}
+// Mount re-initializes non-serializable component objects on every request.
+func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
+ if state.Toasts == nil {
+ toasts := toast.New("notifications",
+ toast.WithPosition(toast.TopRight),
+ toast.WithMaxVisible(3),
+ )
+ toasts.SetStyled(false)
+ state.Toasts = toasts
+ }
+ if state.DeleteConfirm == nil {
+ state.DeleteConfirm = modal.NewConfirm("delete_confirm",
+ modal.WithConfirmTitle("Delete Todo"),
+ modal.WithConfirmMessage("Are you sure you want to delete this todo?"),
+ modal.WithConfirmDestructive(true),
+ modal.WithConfirmText("Delete"),
+ modal.WithCancelText("Cancel"),
+ )
+ }
+ return state, nil
+}
+
// AddTodo handles the "add_todo" action.
func (c *TodoController) AddTodo(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
title := ctx.GetString("title")
diff --git a/todos-progressive/main.go b/todos-progressive/main.go
index edb15e1..567e43c 100644
--- a/todos-progressive/main.go
+++ b/todos-progressive/main.go
@@ -35,8 +35,8 @@ type Todo struct {
}
type TodoState struct {
- Items []Todo
- ActiveFilter string
+ Items []Todo `lvt:"persist"`
+ ActiveFilter string `lvt:"persist"`
}
func (s TodoState) ActiveCount() int {
diff --git a/todos/state.go b/todos/state.go
index 1c37c8f..5a0c7cb 100644
--- a/todos/state.go
+++ b/todos/state.go
@@ -60,13 +60,13 @@ type PaginationInput struct {
// The state is serializable and can be safely passed to templates.
type TodoState struct {
// Display metadata
- Title string `json:"title"`
- Username string `json:"username"`
+ Title string `json:"title" lvt:"persist"`
+ Username string `json:"username" lvt:"persist"`
LastUpdated string `json:"last_updated"`
// Filter and sort settings
- SearchQuery string `json:"search_query"`
- SortBy string `json:"sort_by"`
+ SearchQuery string `json:"search_query" lvt:"persist"`
+ SortBy string `json:"sort_by" lvt:"persist"`
// Todo data
FilteredTodos []TodoItem `json:"filtered_todos"` // After search filter applied
@@ -78,8 +78,8 @@ type TodoState struct {
RemainingCount int `json:"remaining_count"`
// Pagination state
- CurrentPage int `json:"current_page"`
- PageSize int `json:"page_size"`
+ CurrentPage int `json:"current_page" lvt:"persist"`
+ PageSize int `json:"page_size" lvt:"persist"`
TotalPages int `json:"total_pages"`
ShowPagination bool `json:"show_pagination"`
PrevDisabled bool `json:"prev_disabled"`
diff --git a/ws-disabled/main.go b/ws-disabled/main.go
index 9439238..5d7418c 100644
--- a/ws-disabled/main.go
+++ b/ws-disabled/main.go
@@ -18,8 +18,8 @@ type BookmarkController struct{}
// BookmarkState is pure data, cloned per session.
type BookmarkState struct {
Title string `json:"title"`
- Bookmarks []Bookmark `json:"bookmarks"`
- Count int `json:"count"`
+ Bookmarks []Bookmark `json:"bookmarks" lvt:"persist"`
+ Count int `json:"count" lvt:"persist"`
}
// Bookmark represents a single bookmark.