Skip to content
This repository was archived by the owner on May 23, 2026. It is now read-only.
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
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ Every E2E test must follow these rules. When generating tests for a new example,
- lvt (testing): latest pseudo-version
- Client library: served via `e2etest.ServeClientLibrary` (dev) or CDN (production)

### Manual Testing on Mobile (iPhone via Tailscale)

For touch / mobile-viewport / native-browser concerns that headless e2e can't cover, run the example on the dev box and reach it from the phone over Tailscale — replace `localhost` with the dev box's hostname:

```bash
# LVT_LOCAL_CLIENT example path: ../../../client/dist/livetemplate-client.browser.js
# (relative to the example directory, when client repo is sibling to examples).
PORT=8090 LVT_DEV_MODE=true \
LVT_LOCAL_CLIENT=/path/to/client/dist/livetemplate-client.browser.js \
go run .
```

Open `http://<devbox-hostname>:8090/...` on the phone. `LVT_LOCAL_CLIENT` lets you test in-flight client changes that aren't in `@latest` CDN yet — the template's `{{if .lvt.DevMode}}` branch picks it up.

### Reference Examples

- `todos/` — Canonical Tier 1 example: CRUD, auth, pagination, modal + toast components
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ require (
github.com/chromedp/chromedp v0.14.2
github.com/go-playground/validator/v10 v10.30.1
github.com/gorilla/websocket v1.5.3
github.com/livetemplate/livetemplate v0.8.22
github.com/livetemplate/lvt v0.1.5
github.com/livetemplate/livetemplate v0.8.23
github.com/livetemplate/lvt v0.1.6
github.com/livetemplate/lvt/components v0.1.2
modernc.org/sqlite v1.43.0
)
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/livetemplate/livetemplate v0.8.22 h1:fSCGPulVY6dpcgmuuWIcSB9PvndKWYS9A+e51bOQfC0=
github.com/livetemplate/livetemplate v0.8.22/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
github.com/livetemplate/lvt v0.1.5 h1:s5yRAgV6sTY9Mw8b4Zgy2MGrgWWhbk7Gt3K2EFCKt6k=
github.com/livetemplate/lvt v0.1.5/go.mod h1:17cFl500ntymD3gx8h+ZODnVnTictHgG8Wmz/By75sU=
github.com/livetemplate/livetemplate v0.8.23 h1:80/Lwa2iPqKhnJIHvPKWnRObMM2YoRxtAyZ9gE9tN+E=
github.com/livetemplate/livetemplate v0.8.23/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
github.com/livetemplate/lvt v0.1.6 h1:1rDU5hDo+EtZ0mT+868wYD9czF2EHEgdacS4kpIUPQ4=
github.com/livetemplate/lvt v0.1.6/go.mod h1:OrTdx3zvh0WeuugVueQoRG3ILRNJe/dThErxKsos6Rw=
github.com/livetemplate/lvt/components v0.1.2 h1:MM2M5IZnsUAu0py9ZbtcQCo0bvUrL4Z3Ly/yDkYNyag=
github.com/livetemplate/lvt/components v0.1.2/go.mod h1:G9PElN3LRf8xoRtoxbOAcTkV/4FhrCE/Laczkz5bfL4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
Expand Down
38 changes: 38 additions & 0 deletions patterns/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,43 @@ func initialSortableItems() []SortableItem {
}
}

// LargeRow is a row in the Large Table demo. Five fields exercise the
// multi-field volatile-field update workload that closes Open Question 2
// of the streaming-range proposal: a single-field change still emits a
// whole-item ["u"] op carrying all five.
type LargeRow struct {
ID string
Name string
Email string
Status string
Score int
}

// largeTableDefaultSize is the demo's default row count. The e2e test
// overrides it via LARGE_TABLE_SIZE so CI doesn't pay for 10k DOM rows
// while still exercising every controller path.
const largeTableDefaultSize = 10000

var largeTableStatuses = []string{"active", "pending", "blocked", "archived"}

// largeTableSeed builds the deterministic seed dataset. No rand: stable
// hashes across renders are required for the streaming-range diff to
// recognise unchanged items.
func largeTableSeed(n int) []LargeRow {
rows := make([]LargeRow, n)
for i := range rows {
id := i + 1
rows[i] = LargeRow{
ID: fmt.Sprintf("row-%05d", id),
Name: fmt.Sprintf("User %05d", id),
Email: fmt.Sprintf("user%05d@example.com", id),
Status: largeTableStatuses[id%len(largeTableStatuses)],
Score: (id * 37) % 1000,
}
}
return rows
}

// carMakes maps car makes to their model lists. Used by Value Select to
// demonstrate cascading dependent selects.
var carMakes = map[string][]string{
Expand Down Expand Up @@ -258,6 +295,7 @@ func allPatterns() []PatternCategory {
{Name: "Infinite Scroll", Path: "/patterns/lists/infinite-scroll", Description: "Auto-load on scroll with IntersectionObserver", Implemented: true},
{Name: "Value Select", Path: "/patterns/lists/value-select", Description: "Cascading dependent selects", Implemented: true},
{Name: "Sortable List", Path: "/patterns/lists/sortable", Description: "Drag-and-drop reordering with native HTML5 drag events", Implemented: true},
{Name: "Large Table", Path: "/patterns/lists/large-table", Description: "10k-row table with filter, sort, append, update, delete, reset (streaming range)", Implemented: true},
},
},
{
Expand Down
188 changes: 188 additions & 0 deletions patterns/handlers_lists.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package main

import (
"cmp"
"fmt"
"math/rand"
"net/http"
"os"
"slices"
"strconv"
"strings"
"sync"
"time"

"github.com/livetemplate/livetemplate"
)
Expand Down Expand Up @@ -255,3 +262,184 @@ func sortableHandler(baseOpts []livetemplate.Option) http.Handler {
Category: "Lists & Data",
}))
}

// --- Large Table (10k-row streaming-range demo) ---

const (
largeTableSortByName = "name"
largeTableSortByEmail = "email"
largeTableSortByStatus = "status"
largeTableSortByScore = "score"
largeTableSortAsc = "asc"
largeTableSortDesc = "desc"
largeTableAppendBatch = 50
)

// LargeTableController owns the row dataset process-wide. Mu protects
// rows + nextID + rng. Filter/sort live per-session in LargeTableState.
// SeedSize is captured at construction so Reset returns to the same N
// even when overridden via LARGE_TABLE_SIZE.
type LargeTableController struct {
mu sync.Mutex
rows []LargeRow
nextID int
seedSize int
rng *rand.Rand
}

func newLargeTableController() *LargeTableController {
size := largeTableDefaultSize
if v := os.Getenv("LARGE_TABLE_SIZE"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
size = n
}
}
return &LargeTableController{
rows: largeTableSeed(size),
nextID: size + 1,
seedSize: size,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}

func (c *LargeTableController) snapshot() []LargeRow {
c.mu.Lock()
defer c.mu.Unlock()
return slices.Clone(c.rows)
}

// applyView is pure: filters + sorts the snapshot per session settings
// and returns the displayed slice. No controller mutation.
func (c *LargeTableController) applyView(rows []LargeRow, filter, sortKey, sortDir string) []LargeRow {
if filter != "" {
f := strings.ToLower(filter)
filtered := rows[:0]
for _, r := range rows {
if strings.Contains(strings.ToLower(r.Name), f) ||
strings.Contains(strings.ToLower(r.Email), f) {
filtered = append(filtered, r)
}
}
rows = filtered
}
if sortKey != "" {
slices.SortFunc(rows, func(a, b LargeRow) int {
switch sortKey {
case largeTableSortByName:
return strings.Compare(a.Name, b.Name)
case largeTableSortByEmail:
return strings.Compare(a.Email, b.Email)
case largeTableSortByStatus:
return strings.Compare(a.Status, b.Status)
case largeTableSortByScore:
return cmp.Compare(a.Score, b.Score)
}
return 0
Comment on lines +327 to +337
})
if sortDir == largeTableSortDesc {
slices.Reverse(rows)
}
}
return rows
}

func (c *LargeTableController) refreshView(state LargeTableState) LargeTableState {
snap := c.snapshot()
state.Total = len(snap)
state.Items = c.applyView(snap, state.Filter, state.SortKey, state.SortDir)
Comment on lines +347 to +349
return state
}

func (c *LargeTableController) Mount(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) {
return c.refreshView(state), nil
}

// Change handles the filter input. Auto-wired by the framework on inputs
// with name="filter" (300ms debounce).
func (c *LargeTableController) Change(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) {
if ctx.Has("filter") {
state.Filter = ctx.GetString("filter")
}
return c.refreshView(state), nil
}

// Sort toggles direction on the same column or sorts ascending on a new one.
// Wired to a button with name="sort" carrying the column key in `value`.
func (c *LargeTableController) Sort(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) {
key := ctx.GetString("value")
if key == "" {
return state, nil
}
if state.SortKey == key && state.SortDir == largeTableSortAsc {
state.SortDir = largeTableSortDesc
} else {
state.SortKey = key
state.SortDir = largeTableSortAsc
}
return c.refreshView(state), nil
}

// AppendN adds largeTableAppendBatch rows to the end of the table.
func (c *LargeTableController) AppendN(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) {
c.mu.Lock()
for i := 0; i < largeTableAppendBatch; i++ {
id := c.nextID
c.nextID++
c.rows = append(c.rows, LargeRow{
ID: fmt.Sprintf("row-%05d", id),
Name: fmt.Sprintf("User %05d", id),
Email: fmt.Sprintf("user%05d@example.com", id),
Status: largeTableStatuses[id%len(largeTableStatuses)],
Score: (id * 37) % 1000,
})
}
c.mu.Unlock()
return c.refreshView(state), nil
}

// UpdateRandomRow increments Score on a random row. The streaming-range
// proposal's worst-case workload for whole-item updates: a single field
// change still emits one ["u"] op carrying every dynamic position. Closes
// Open Question 2 — the wire cost measured here decides whether a future
// targeted-field op is needed.
func (c *LargeTableController) UpdateRandomRow(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) {
c.mu.Lock()
if len(c.rows) > 0 {
idx := c.rng.Intn(len(c.rows))
c.rows[idx].Score = (c.rows[idx].Score + 1) % 1000
}
c.mu.Unlock()
return c.refreshView(state), nil
}

// Delete removes the row whose ID matches the clicked button's value.
func (c *LargeTableController) Delete(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) {
id := ctx.GetString("value")
c.mu.Lock()
c.rows = slices.DeleteFunc(c.rows, func(r LargeRow) bool { return r.ID == id })
c.mu.Unlock()
return c.refreshView(state), nil
}

// Reset restores the seed dataset and clears filter/sort.
func (c *LargeTableController) Reset(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) {
c.mu.Lock()
c.rows = largeTableSeed(c.seedSize)
c.nextID = c.seedSize + 1
c.mu.Unlock()
state.Filter = ""
state.SortKey = ""
state.SortDir = ""
return c.refreshView(state), nil
}

func largeTableHandler(baseOpts []livetemplate.Option) http.Handler {
opts := append(slices.Clone(baseOpts),
livetemplate.WithParseFiles("templates/layout.tmpl", "templates/lists/large-table.tmpl"),
)
tmpl := livetemplate.Must(livetemplate.New("layout", opts...))
return tmpl.Handle(newLargeTableController(), livetemplate.AsState(&LargeTableState{
Title: "Large Table",
Category: "Lists & Data",
}))
}
Loading
Loading