From a488bbf832f85a4f41c23c5d57cb8e2320d4ba1c Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 04:24:36 -0600 Subject: [PATCH] Stop rebuilding the hand log after every fold, call and raise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiled a long night at a full table — 24 seats, 200 logged hands — because that's the state the tool is slowest in and the one a dealer reaches at 2am. A full re-render cost 30ms, and it runs after every single action. Broken down, 25.7ms of that was renderHandHistory: tearing down and rebuilding 200 rows of a panel that hadn't changed and that nobody is looking at mid-hand. Everything else on the table put together came to about 4ms. The log only changes when a hand is settled or the history is cleared — maybe once every thirty taps. It now carries a cheap signature (length, hand counter, and the newest row) and is skipped when that hasn't moved. Entries are immutable once written and only ever added at the front, so the signature catches a recorded hand, a cleared history, and an undo that puts a hand back. renderAll, every action 30.07ms -> 4.18ms renderHandHistory, skipped 25.74ms -> 0ms renderHandHistory, rebuild 25.74ms -> 9.05ms The rebuild got faster too, for the times it does run — once per hand, where 25ms of jank lands exactly on the "next hand" tap. toLocaleTimeString was the single most expensive call in the loop and a logged hand's timestamp never changes, so the formatted string is kept; and the rows are built into a fragment so the panel lays out once instead of two hundred times. A cache like this is only worth having if it can't go stale — a dealer reading last hand's payouts while settling this one is worse than a slow panel. 10 tests cover the invalidation directly: unchanged through a street of betting, changed by settling, by clearing, by an undo back to a shorter log, by two hands that differ only in the pot, and by the counter moving on. Validated with Playwright against the real page: the rendered rows match the stored history through settling a hand in the wizard, undoing it, clearing the log and recording again. Touch and layout guarantees from the earlier rounds still hold — 0 of 87 controls on touch-action auto, 0 tap-highlighted, every stack still inside its own puck. 109 tests passing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- .../wwwroot/js/casino/holdem/render.js | 49 +++++++- tests/holdem/historyCache.test.js | 118 ++++++++++++++++++ tests/holdem/load.js | 5 + 3 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 tests/holdem/historyCache.test.js diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 2e21b50..eebe5b1 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -16,6 +16,10 @@ const Render = { this.state = state; this.hooks = hooks; this.selectedSeatIndex = null; + // A reset hands over a different table entirely, so nothing cached about the + // last one still applies. + this.lastHistorySignature = null; + this.handTimeCache.clear(); this.renderAll(); }, @@ -703,24 +707,57 @@ const Render = { // ---- Hand counter + history ---- + // toLocaleTimeString is the single most expensive call in the history panel, and a + // logged hand's timestamp never changes, so the answer is worth keeping. + handTimeCache: new Map(), + formatHandTime(timestamp) { + if (this.handTimeCache.has(timestamp)) return this.handTimeCache.get(timestamp); + let formatted = ""; try { - return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + formatted = new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); } catch { - return ""; + formatted = ""; } + this.handTimeCache.set(timestamp, formatted); + return formatted; }, - renderHandHistory() { + // Cheap stand-in for "has the log changed". Entries are immutable once written and + // only ever added at the front, so the length, the counter and the newest row + // between them catch a recorded hand, a cleared history and an undo. + historySignature() { + const history = this.state.handHistory || []; + const newest = history[0]; + return [ + history.length, + this.state.handNumber || 1, + newest ? newest.handNumber : "", + newest ? newest.timestamp : "", + newest ? newest.pot : "", + ].join("|"); + }, + + // Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a + // full re-render runs after every fold, call and raise. The log only changes when a + // hand is settled or the history is cleared, perhaps once every thirty taps, so it + // is skipped unless it actually did change. Everything else on the table reflects + // the current hand and still redraws every time. + renderHandHistory(force = false) { + const signature = this.historySignature(); + if (!force && signature === this.lastHistorySignature) return; + this.lastHistorySignature = signature; + document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`; const list = document.getElementById("handHistoryList"); const emptyHint = document.getElementById("handHistoryEmptyHint"); const history = this.state.handHistory || []; - list.innerHTML = ""; emptyHint.hidden = history.length > 0; document.getElementById("exportHistoryBtn").disabled = history.length === 0; + // Built off-document so the panel is laid out once instead of once per row. + const fragment = document.createDocumentFragment(); const template = document.getElementById("handHistoryRowTemplate"); history.forEach(entry => { const row = template.content.firstElementChild.cloneNode(true); @@ -731,8 +768,10 @@ const Render = { const payoutsEl = row.querySelector(".handHistoryPayouts"); payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", "); - list.appendChild(row); + fragment.appendChild(row); }); + list.innerHTML = ""; + list.appendChild(fragment); }, }; diff --git a/tests/holdem/historyCache.test.js b/tests/holdem/historyCache.test.js new file mode 100644 index 0000000..bc857f3 --- /dev/null +++ b/tests/holdem/historyCache.test.js @@ -0,0 +1,118 @@ +// The hand-history panel is only rebuilt when its signature changes, which takes a +// full re-render from ~30ms to ~3ms. The whole saving depends on that signature +// noticing every way the log can actually change — a stale panel would have the +// dealer reading last hand's payouts while settling this one. +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Render, HoldemState, makeTable } = require("./load"); + +// historySignature() reads Render.state, so point it at a table for each case. +const signatureOf = state => { + Render.state = state; + return Render.historySignature(); +}; + +const tableWithHands = (count) => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2] }); + for (let i = 0; i < count; i++) { + state.seats[0].amountOverride = 2; + state.seats[1].amountOverride = 2; + HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]); + } + return state; +}; + +describe("the history panel knows when to redraw", () => { + test("nothing changes while a hand is being played", () => { + const state = tableWithHands(3); + const before = signatureOf(state); + + // A whole street of betting touches no part of the log. + state.seats[0].committed = 5; + state.seats[1].folded = true; + state.actingSeatIndex = 2; + state.street = "flop"; + + assert.equal(signatureOf(state), before, "betting must not force a rebuild"); + }); + + test("settling a hand changes it", () => { + const state = tableWithHands(3); + const before = signatureOf(state); + HoldemState.recordHand(state, [{ id: state.seats[1].id, label: "Ben", amount: 12 }]); + assert.notEqual(signatureOf(state), before); + }); + + test("clearing the history changes it", () => { + const state = tableWithHands(5); + const before = signatureOf(state); + HoldemState.clearHistory(state); + assert.notEqual(signatureOf(state), before); + }); + + test("undoing back to a shorter log changes it", () => { + const state = tableWithHands(2); + const snapshot = JSON.parse(JSON.stringify(state)); + HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 30 }]); + const afterRecord = signatureOf(state); + + // The undo path swaps the state's contents in place. + Object.keys(state).forEach(k => delete state[k]); + Object.assign(state, snapshot); + + assert.notEqual(signatureOf(state), afterRecord, "an undone hand must disappear from the panel"); + }); + + test("two hands that differ only in payout are told apart", () => { + const a = tableWithHands(1); + const b = tableWithHands(1); + // Same count and hand number; different pot. + b.handHistory[0].pot = a.handHistory[0].pot + 25; + assert.notEqual(signatureOf(a), signatureOf(b)); + }); + + test("the hand counter moving on changes it", () => { + // The counter badge is drawn by the same function, so a new hand number has + // to invalidate even when the log itself is untouched. + const state = tableWithHands(2); + const before = signatureOf(state); + state.handNumber += 1; + assert.notEqual(signatureOf(state), before); + }); + + test("an empty log has a stable signature of its own", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1] }); + const first = signatureOf(state); + assert.equal(signatureOf(state), first); + + HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]); + assert.notEqual(signatureOf(state), first, "the first hand ever must draw the panel"); + }); +}); + +describe("the hand-time cache", () => { + test("the same timestamp is only formatted once, and formats the same", () => { + Render.handTimeCache.clear(); + const stamp = Date.UTC(2026, 8, 1, 15, 4); + const first = Render.formatHandTime(stamp); + assert.equal(Render.handTimeCache.size, 1); + + const second = Render.formatHandTime(stamp); + assert.equal(second, first); + assert.equal(Render.handTimeCache.size, 1, "not re-formatted"); + }); + + test("different timestamps still format differently", () => { + Render.handTimeCache.clear(); + const a = Render.formatHandTime(Date.UTC(2026, 8, 1, 15, 4)); + const b = Render.formatHandTime(Date.UTC(2026, 8, 1, 18, 47)); + assert.notEqual(a, b); + assert.equal(Render.handTimeCache.size, 2); + }); + + test("a nonsense timestamp doesn't throw", () => { + Render.handTimeCache.clear(); + assert.doesNotThrow(() => Render.formatHandTime(undefined)); + assert.doesNotThrow(() => Render.formatHandTime("not a date")); + }); +}); diff --git a/tests/holdem/load.js b/tests/holdem/load.js index f2662c2..b38980f 100644 --- a/tests/holdem/load.js +++ b/tests/holdem/load.js @@ -20,6 +20,10 @@ const SplitCalculator = load("splitCalculator"); const Betting = load("betting"); const HandHistoryExport = load("handHistoryExport"); +// render.js exports { Render } rather than the namespace directly. +const Render = require(path.join(SRC, "render.js")).Render; +globalThis.Render = Render; + // state.js exports `HoldemState`, which doesn't match its filename. const HoldemState = require(path.join(SRC, "state.js")); globalThis.HoldemState = HoldemState; @@ -69,6 +73,7 @@ function contributions(state) { module.exports = { Money, + Render, HandHistoryExport, PotCalculator, SplitCalculator,