From b4386307964cc35ae3f7099cd5407b9ab13a2ea9 Mon Sep 17 00:00:00 2001 From: Hans de Zwart Date: Tue, 11 Aug 2026 15:40:11 +0200 Subject: [PATCH] Only touch the DOM when a displayed value changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readout hung and then skipped numbers in a focused desktop window. render() runs every animation frame and was writing unconditionally — measured at 11 DOM mutations per frame — whether or not anything had changed. Two of those are the .time text node, a glyph run up to 190px, so every frame forced a style recalc and a full relayout to redraw digits that turn over once a second. Routing the per-frame writes through change guards takes a steady frame from 11 writes to 0. The timing itself was never at fault and is untouched here. elapsed() is one subtraction from a single monotonic performance.now() stamp and the reserve is debited once, in tap(), so nothing accumulates per frame and a dropped frame cannot cost or gift anyone a millisecond. Measured against wall clock over a running turn: 0.0000ms drift across 5s, 12s and 17s segments. Also fixes a real skip in fmt(). It switched from m:ss to tenths at ms < 10000 with both branches rounding up, so the coarse branch read a second high: counting down gave 0:11 for a full second, 0:10 for a single millisecond, then 10.0. Handing 10000ms to the tenths branch gives a monotone 0:11 -> 10.0 -> 9.9. Co-Authored-By: Claude Opus 5 --- public_html/index.html | 44 +++++++++++++++++++++++++++++++----------- public_html/sw.js | 2 +- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/public_html/index.html b/public_html/index.html index 983e2b1..b231531 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -659,7 +659,9 @@ function fmt(ms){ if(ms < 0) ms = 0; var t = Math.ceil(ms / 100) / 10; - if(ms < 10000) return t.toFixed(1); + // <= so 10000ms renders as "10.0" rather than "0:10": both branches round up, + // so the coarse one reads a second high and 0:10 would flash for 1ms + if(ms <= 10000) return t.toFixed(1); var s = Math.ceil(ms / 1000); var m = Math.floor(s / 60); s = s % 60; @@ -679,6 +681,24 @@ return Math.max(0, st.reserve[i] - consumed(i)); } + /* ---- write only on change ---- + render() runs 60 times a second but the digits turn over once a second, and + .time is a ~190px glyph run: rewriting its text node every frame forces a + style recalc and relayout for nothing, which is what makes the readout + stutter. The cached marker avoids reading the DOM back, which would flush + style by itself. */ + function setText(node, value){ + var s = String(value); + if(node.__v !== s){ node.__v = s; node.textContent = s; } + } + function setAttr(node, name, value){ + if(node.getAttribute(name) !== value) node.setAttribute(name, value); + } + function setStyle(node, prop, value){ + var k = "__s_" + prop; // per property, so a second one can't alias + if(node[k] !== value){ node[k] = value; node.style[prop] = value; } + } + /* ============ render ============ */ function render(){ var e = elapsed(); @@ -697,21 +717,23 @@ if(live && main <= 0 && st.phase === RUN){ flag(i); return; } - el[i].time.textContent = fmt(main); - el[i].moves.textContent = st.moves[i]; - el[i].you.textContent = st.score[i]; // each panel is "You" to its own player - el[i].them.textContent = st.score[1 - i]; - el[i].target.textContent = cfg.points; + setText(el[i].time, fmt(main)); + setText(el[i].moves, st.moves[i]); + setText(el[i].you, st.score[i]); // each panel is "You" to its own player + setText(el[i].them, st.score[1 - i]); + setText(el[i].target, cfg.points); var showDelay = live && cfg.delay > 0 && dLeft > 0; - el[i].delay.classList.toggle("on", showDelay); + el[i].delay.classList.toggle("on", showDelay); // no-op when already right if(showDelay){ - el[i].fill.style.transform = "scaleX(" + (dLeft / cfg.delay) + ")"; - el[i].dnum.textContent = Math.ceil(dLeft / 1000); + // the bar really does move every frame, but transform is compositor-only + setStyle(el[i].fill, "transform", "scaleX(" + (dLeft / cfg.delay).toFixed(4) + ")"); + setText(el[i].dnum, Math.ceil(dLeft / 1000)); } - if(st.phase === FLAG && i === st.active) el[i].root.dataset.state = "flagged"; - else el[i].root.dataset.state = (i === st.active && st.phase !== IDLE) ? "active" : "idle"; + setAttr(el[i].root, "data-state", + (st.phase === FLAG && i === st.active) ? "flagged" + : (i === st.active && st.phase !== IDLE) ? "active" : "idle"); } } diff --git a/public_html/sw.js b/public_html/sw.js index 47e4768..61214cc 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,5 +1,5 @@ /* Cache-first: once installed the clock never touches the network again. */ -const CACHE = "bgclock-v6"; +const CACHE = "bgclock-v7"; const FILES = [ "./", "./index.html",