diff --git a/CLAUDE.md b/CLAUDE.md index 2c1f349..97ce096 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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://: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 diff --git a/go.mod b/go.mod index daef356..c5bdc6a 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 59c88f5..5222460 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/patterns/data.go b/patterns/data.go index 89b871e..e886445 100644 --- a/patterns/data.go +++ b/patterns/data.go @@ -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{ @@ -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}, }, }, { diff --git a/patterns/handlers_lists.go b/patterns/handlers_lists.go index 6eafe70..9c064cd 100644 --- a/patterns/handlers_lists.go +++ b/patterns/handlers_lists.go @@ -1,9 +1,16 @@ package main import ( + "cmp" + "fmt" + "math/rand" "net/http" + "os" "slices" + "strconv" + "strings" "sync" + "time" "github.com/livetemplate/livetemplate" ) @@ -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 + }) + 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) + 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", + })) +} diff --git a/patterns/large_table_bench_test.go b/patterns/large_table_bench_test.go new file mode 100644 index 0000000..9349421 --- /dev/null +++ b/patterns/large_table_bench_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "bytes" + "io" + "testing" + + "github.com/livetemplate/livetemplate" +) + +// BenchmarkLargeTable_UpdateRandomRow_WireBytes closes Open Question 2 in +// the streaming-range proposal: at N=10k, a single-field whole-item update +// must stay below the wire-cost ceiling that would justify a future +// targeted-field op (`["uf", key, fieldIdx, value]`). Project policy +// (per the user-approved Phase 6 plan): ceiling is 30% of full-tree size. +// +// The benchmark renders once (Execute → first render with statics), then +// calls ExecuteUpdates twice — first to transition to stream mode, then +// repeatedly to measure the per-update wire cost reported via b.ReportMetric. +// +// Each iteration mutates ONE field on ONE row; the wire output should be +// dominated by a single ["u", key, dynamics] op carrying all five fields +// of that row. With no sort active, no reorder op is emitted. +func BenchmarkLargeTable_UpdateRandomRow_WireBytes(b *testing.B) { + cases := []struct { + name string + n int + }{ + {"N=200", 200}, + {"N=1000", 1000}, + {"N=10000", 10000}, + } + for _, tc := range cases { + b.Run(tc.name, func(b *testing.B) { + tmpl := livetemplate.Must(livetemplate.New("layout", + livetemplate.WithParseFiles( + "templates/layout.tmpl", + "templates/lists/large-table.tmpl", + ), + )) + + c := newLargeTableController() + c.rows = largeTableSeed(tc.n) + c.nextID = tc.n + 1 + c.seedSize = tc.n + + state := c.refreshView(LargeTableState{ + Title: "Large Table", + Category: "Lists & Data", + }) + + if err := tmpl.Execute(io.Discard, state); err != nil { + b.Fatalf("initial Execute: %v", err) + } + if err := tmpl.ExecuteUpdates(io.Discard, state); err != nil { + b.Fatalf("transition ExecuteUpdates: %v", err) + } + + var buf bytes.Buffer + b.ResetTimer() + b.ReportAllocs() + + var totalBytes int64 + for i := 0; i < b.N; i++ { + idx := i % tc.n + c.mu.Lock() + c.rows[idx].Score = (c.rows[idx].Score + 1) % 1000 + c.mu.Unlock() + state = c.refreshView(state) + + buf.Reset() + if err := tmpl.ExecuteUpdates(&buf, state); err != nil { + b.Fatalf("ExecuteUpdates iter %d: %v", i, err) + } + totalBytes += int64(buf.Len()) + } + b.ReportMetric(float64(totalBytes)/float64(b.N), "wire-B/op") + }) + } +} + +// BenchmarkLargeTable_FullTreeBaseline_WireBytes is the legacy comparison +// point — render the FIRST tree (which always carries statics + every +// dynamic) and report its byte size. This is the size every subsequent +// render would carry if streaming-range diff did NOT exist (or fell back +// to full-tree replacement). Used for OQ2's "% of full-tree" ratio. +func BenchmarkLargeTable_FullTreeBaseline_WireBytes(b *testing.B) { + cases := []struct { + name string + n int + }{ + {"N=200", 200}, + {"N=1000", 1000}, + {"N=10000", 10000}, + } + for _, tc := range cases { + b.Run(tc.name, func(b *testing.B) { + tmpl := livetemplate.Must(livetemplate.New("layout", + livetemplate.WithParseFiles( + "templates/layout.tmpl", + "templates/lists/large-table.tmpl", + ), + )) + + c := newLargeTableController() + c.rows = largeTableSeed(tc.n) + c.nextID = tc.n + 1 + c.seedSize = tc.n + + state := c.refreshView(LargeTableState{ + Title: "Large Table", + Category: "Lists & Data", + }) + + var buf bytes.Buffer + b.ResetTimer() + b.ReportAllocs() + + var totalBytes int64 + for i := 0; i < b.N; i++ { + buf.Reset() + if err := tmpl.Execute(&buf, state); err != nil { + b.Fatalf("Execute iter %d: %v", i, err) + } + totalBytes += int64(buf.Len()) + } + b.ReportMetric(float64(totalBytes)/float64(b.N), "wire-B/op") + }) + } +} diff --git a/patterns/main.go b/patterns/main.go index c7b12d6..cf6f0be 100644 --- a/patterns/main.go +++ b/patterns/main.go @@ -80,6 +80,7 @@ func main() { mux.Handle("/patterns/lists/infinite-scroll", infiniteScrollHandler(baseOpts)) mux.Handle("/patterns/lists/value-select", valueSelectHandler(baseOpts)) mux.Handle("/patterns/lists/sortable", sortableHandler(baseOpts)) + mux.Handle("/patterns/lists/large-table", largeTableHandler(baseOpts)) // Category: Search & Filtering (#12–#13) mux.Handle("/patterns/search/active-search", activeSearchHandler(baseOpts)) diff --git a/patterns/patterns_test.go b/patterns/patterns_test.go index cfde85d..73f5685 100644 --- a/patterns/patterns_test.go +++ b/patterns/patterns_test.go @@ -1330,6 +1330,221 @@ func TestSortable(t *testing.T) { }) } +// --- Large Table (10k-row streaming-range demo) --- + +func TestLargeTable(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + // CI uses a 200-row dataset; the demo defaults to 10k. The smaller + // dataset still exercises every controller path and every range op + // the streaming-range diff emits, while keeping subtest latency low. + t.Setenv("LARGE_TABLE_SIZE", "200") + + ctx, cancel, serverPort := setupTest(t) + defer cancel() + + url := e2etest.GetChromeTestURL(serverPort) + "/patterns/lists/large-table" + + frames := e2etest.RecordWSFrames(ctx) + + t.Run("Initial_Load_Renders_All_Rows", func(t *testing.T) { + err := chromedp.Run(ctx, + chromedp.Navigate(url), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`#large-table-pattern`, chromedp.ByQuery), + e2etest.WaitForCount(`tbody tr[data-key]`, 200, 30*time.Second), + e2etest.WaitForText(`#large-table-count`, "Showing 200 of 200 rows.", 5*time.Second), + ) + if err != nil { + t.Fatalf("Initial load failed: %v", err) + } + }) + + t.Run("UI_Standards", func(t *testing.T) { + runUIStandards(t, ctx) + }) + + t.Run("Filter_Reduces_Visible_Rows", func(t *testing.T) { + // "00099" matches User 00099 only (single row out of 200). + err := chromedp.Run(ctx, + chromedp.Focus(`input[name="filter"]`, chromedp.ByQuery), + chromedp.SendKeys(`input[name="filter"]`, "00099", chromedp.ByQuery), + e2etest.WaitForCount(`tbody tr[data-key]`, 1, 5*time.Second), + e2etest.WaitForText(`#large-table-count`, "Showing 1 of 200 rows.", 5*time.Second), + ) + if err != nil { + t.Fatalf("Filter did not narrow rows: %v", err) + } + }) + + t.Run("Filter_Clear_Restores_All_Rows", func(t *testing.T) { + // chromedp.Clear doesn't fire the input event the auto-wirer needs; + // set value and dispatch input/change manually (mirrors the pattern + // in TestActiveSearch). + var filterValue string + err := chromedp.Run(ctx, + chromedp.Focus(`input[name="filter"]`, chromedp.ByQuery), + chromedp.Evaluate(`(() => { + const el = document.querySelector('input[name="filter"]'); + el.value = ''; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return el.value; + })()`, nil), + e2etest.WaitForCount(`tbody tr[data-key]`, 200, 10*time.Second), + chromedp.Value(`input[name="filter"]`, &filterValue, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Filter clear did not restore: %v", err) + } + if filterValue != "" { + t.Errorf("Expected filter input to be cleared, got %q", filterValue) + } + }) + + t.Run("Update_Random_Row_Bounded_WS_Frame", func(t *testing.T) { + // Bounded-WS-size assertion (proposal §379, §386 OQ2): with no sort + // applied, a single-field change on a 5-field row must emit a small + // whole-item ["u"] op (~hundreds of bytes), NOT a full-tree + // replacement (KBs at this scale). 1.5KB is the test-tier ceiling — + // well above whole-item op size, well below full-tree size at N=200. + // Sort-active scenarios add a reorder op and are bounded separately + // in Update_With_Sort_Active_Bounded_WS_Frame below. + const wsFrameCeilingBytes = 1536 + + frames.Clear() + err := chromedp.Run(ctx, + chromedp.Click(`button[name="updateRandomRow"]`, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Click failed: %v", err) + } + // Wait for any received frame from the server. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if frames.CountByDirection("received") > 0 { + break + } + time.Sleep(50 * time.Millisecond) + } + if frames.CountByDirection("received") == 0 { + t.Fatalf("No received frame after UpdateRandomRow click") + } + + var maxBytes int + var maxMsg string + for _, msg := range frames.GetReceived() { + if len(msg.Data) > maxBytes { + maxBytes = len(msg.Data) + maxMsg = msg.Data + } + } + if maxBytes > wsFrameCeilingBytes { + head := maxMsg + if len(head) > 600 { + head = head[:600] + "...(truncated)" + } + t.Errorf("UpdateRandomRow WS frame exceeded streaming-range ceiling: got %d B, ceiling %d B\nFrame head: %s", maxBytes, wsFrameCeilingBytes, head) + } + t.Logf("UpdateRandomRow (no sort) max received frame: %d bytes (ceiling %d B)", maxBytes, wsFrameCeilingBytes) + }) + + t.Run("Sort_By_Score_Toggles_Direction", func(t *testing.T) { + var firstAsc, firstDesc string + err := chromedp.Run(ctx, + chromedp.Click(`button[name="sort"][value="score"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.querySelector('button[name="sort"][value="score"]').textContent.includes('↑')`, 5*time.Second), + chromedp.Text(`tbody tr:first-child td:nth-child(4)`, &firstAsc, chromedp.ByQuery), + chromedp.Click(`button[name="sort"][value="score"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.querySelector('button[name="sort"][value="score"]').textContent.includes('↓')`, 5*time.Second), + chromedp.Text(`tbody tr:first-child td:nth-child(4)`, &firstDesc, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Sort toggle failed: %v", err) + } + if firstAsc == firstDesc { + t.Errorf("Expected different first-row score after toggle, got %q both directions", firstAsc) + } + }) + + t.Run("Append_50_Grows_Total", func(t *testing.T) { + err := chromedp.Run(ctx, + chromedp.Click(`button[name="appendN"]`, chromedp.ByQuery), + e2etest.WaitForText(`#large-table-count`, "Showing 250 of 250 rows.", 5*time.Second), + ) + if err != nil { + t.Fatalf("Append failed: %v", err) + } + }) + + t.Run("Update_With_Sort_Active_Bounded_WS_Frame", func(t *testing.T) { + // With sort-by-score active and Append_50 having grown the table to + // 250 rows, an UpdateRandomRow shifts the changed row's rank in the + // sorted view, triggering an additional ["o", new-keys] reorder op. + // Reorder ops carry one key per row, so the ceiling scales linearly + // with N. At N=250 with ~12-char keys, expect ~3-4KB total. + const wsFrameCeilingBytes = 5120 + + frames.Clear() + err := chromedp.Run(ctx, + chromedp.Click(`button[name="updateRandomRow"]`, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Click failed: %v", err) + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if frames.CountByDirection("received") > 0 { + break + } + time.Sleep(50 * time.Millisecond) + } + if frames.CountByDirection("received") == 0 { + t.Fatalf("No received frame after UpdateRandomRow click") + } + + var maxBytes int + var maxMsg string + for _, msg := range frames.GetReceived() { + if len(msg.Data) > maxBytes { + maxBytes = len(msg.Data) + maxMsg = msg.Data + } + } + if maxBytes > wsFrameCeilingBytes { + head := maxMsg + if len(head) > 600 { + head = head[:600] + "...(truncated)" + } + t.Errorf("UpdateRandomRow with sort exceeded streaming-range+reorder ceiling: got %d B, ceiling %d B\nFrame head: %s", maxBytes, wsFrameCeilingBytes, head) + } + t.Logf("UpdateRandomRow (sort active) max received frame: %d bytes (ceiling %d B)", maxBytes, wsFrameCeilingBytes) + }) + + t.Run("Delete_Single_Row", func(t *testing.T) { + err := chromedp.Run(ctx, + chromedp.Click(`tbody tr[data-key="row-00050"] button[name="delete"]`, chromedp.ByQuery), + e2etest.WaitForText(`#large-table-count`, "Showing 249 of 249 rows.", 5*time.Second), + ) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + }) + + t.Run("Reset_Restores_Initial_Count", func(t *testing.T) { + err := chromedp.Run(ctx, + chromedp.Click(`button[name="reset"]`, chromedp.ByQuery), + e2etest.WaitForCount(`tbody tr[data-key]`, 200, 10*time.Second), + e2etest.WaitForText(`#large-table-count`, "Showing 200 of 200 rows.", 5*time.Second), + ) + if err != nil { + t.Fatalf("Reset failed: %v", err) + } + }) +} + // --- Pattern #12: Active Search --- func TestActiveSearch(t *testing.T) { diff --git a/patterns/state_lists.go b/patterns/state_lists.go index af1fbbd..d4d029c 100644 --- a/patterns/state_lists.go +++ b/patterns/state_lists.go @@ -45,3 +45,16 @@ type SortableItem struct { Key string Name string } + +// LargeTableState holds the per-session view of the Large Table demo. +// Filter/SortKey/SortDir are session-local; the underlying row dataset +// lives process-wide on the controller. +type LargeTableState struct { + Title string + Category string + Items []LargeRow + Filter string + SortKey string + SortDir string + Total int +} diff --git a/patterns/templates/lists/large-table.tmpl b/patterns/templates/lists/large-table.tmpl new file mode 100644 index 0000000..48ebff7 --- /dev/null +++ b/patterns/templates/lists/large-table.tmpl @@ -0,0 +1,55 @@ +{{define "content"}} +
+
+

Large Table

+

10,000 rows with stable keys. Filter, sort, append, update, and delete — every range op the streaming-range diff emits is reachable here. The server retains per-item hashes (~30 B/row) instead of full TreeNodes (~150 B/row), so 10k rows stay interactive at modest memory cost.

+
+ +
+
+ +
+
+ +

Showing {{len .Items}} of {{.Total}} rows.

+ +
+
+ + + +
+
+ + + + + + + + + + + + + {{range .Items}} + + + + + + + + {{end}} + +
{{.Name}}{{.Email}}{{.Status}}{{.Score}} +
+ +
+
+ + {{if eq (len .Items) 0}} +

No rows match the filter.

+ {{end}} +
+{{end}}