From 887d2296ccaf3e647e9fef765c9fef25a81871a7 Mon Sep 17 00:00:00 2001 From: Carl Worth Date: Sun, 8 Mar 2026 18:13:07 -0400 Subject: [PATCH] An attempt at catching some state drift bugs (and adding logging as well) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit I recently noticed a letter get "stuck" in the center area, (it appeared to be there, but did not allow any interaction). This commit is by claude which it described as follows, (note: in the below, rAF is requestAnimationFrame): * Fixed the likely culprit — rAF loop using stale state: The old code checked this.state.revealing[tile.id] synchronously after an async setState to decide whether to keep animating. Now it uses a local running flag, and the loop termination is decided inside the setState callback where it has access to the actual current state. * Defensive cleanup in receive_center: When the server sends an authoritative center update, any revealing entries for tiles no longer in center are now cleaned up. This prevents ghost revealing state from accumulating. * Periodic consistency check (every 5s): - Detects stale revealing entries for tiles not in center, logs a warning, and auto-cleans them - Detects inconsistent claim state The combination of the rAF fix and the defensive cleanup in receive_center should prevent the stuck-tile scenario. And if something else causes drift in the future, the console warnings will help diagnose it. --- anagrams/anagrams.jsx | 70 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/anagrams/anagrams.jsx b/anagrams/anagrams.jsx index 4749d7a..929f57d 100644 --- a/anagrams/anagrams.jsx +++ b/anagrams/anagrams.jsx @@ -142,10 +142,35 @@ class Game extends React.Component { componentDidMount() { this._onKeyDown = (e) => this.on_key_down(e); document.addEventListener("keydown", this._onKeyDown); + + /* Periodic state consistency check (debug aid). */ + this._debug_interval = setInterval(() => this._check_state(), 5000); } componentWillUnmount() { document.removeEventListener("keydown", this._onKeyDown); + if (this._debug_interval) clearInterval(this._debug_interval); + } + + _check_state() { + const { center, revealing, claim_active, claim_player } = this.state; + const center_ids = new Set(center.map(t => t.id)); + + for (const id of Object.keys(revealing)) { + if (!center_ids.has(Number(id))) { + console.warn("[anagrams] Stale revealing entry for tile", id, + "not in center — cleaning up"); + this.setState(prev => { + const r = { ...prev.revealing }; + delete r[id]; + return { revealing: r }; + }); + } + } + + if (claim_active && !claim_player) { + console.warn("[anagrams] claim_active but no claim_player"); + } } /***************************************************** @@ -183,7 +208,20 @@ class Game extends React.Component { new_count++; } } - this.setState({ center: tiles, tile_positions: positions }, () => { + /* Clean up revealing entries for tiles no longer in center. */ + const tile_ids = new Set(tiles.map(t => t.id)); + const revealing = { ...this.state.revealing }; + let reveal_cleaned = false; + for (const id of Object.keys(revealing)) { + if (!tile_ids.has(Number(id))) { + delete revealing[id]; + reveal_cleaned = true; + } + } + const update = { center: tiles, tile_positions: positions }; + if (reveal_cleaned) update.revealing = revealing; + + this.setState(update, () => { /* On bulk load (reconnection), arrange tiles in a grid. */ if (new_count > 1) this.shuffle_tiles(); }); @@ -213,29 +251,33 @@ class Game extends React.Component { * each fading from full opacity to transparent. */ const phase_ms = countdown_ms / 3; const start = Date.now(); + let running = true; const step = () => { - this.setState(prev => { - const r = { ...prev.revealing }; - const entry = r[tile.id]; - if (!entry) return { revealing: r }; + if (!running) return; - const elapsed = Date.now() - start; + const elapsed = Date.now() - start; - if (elapsed >= countdown_ms) { + if (elapsed >= countdown_ms) { + running = false; + this.setState(prev => { + const r = { ...prev.revealing }; delete r[tile.id]; return { revealing: r }; - } + }); + return; + } - const phase = Math.min(2, Math.floor(elapsed / phase_ms)); - const current_number = 3 - phase; - const frac = (elapsed - phase * phase_ms) / phase_ms; + const phase = Math.min(2, Math.floor(elapsed / phase_ms)); + const current_number = 3 - phase; + const frac = (elapsed - phase * phase_ms) / phase_ms; + this.setState(prev => { + const r = { ...prev.revealing }; + if (!r[tile.id]) { running = false; return null; } r[tile.id] = { number: current_number, opacity: 1 - frac }; return { revealing: r }; }); - if (this.state.revealing[tile.id]) { - requestAnimationFrame(step); - } + requestAnimationFrame(step); }; requestAnimationFrame(step); } -- 2.45.2