From 3df9f29b1da31cf4c3d6faee4d9425c750387897 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 28 Oct 2026 23:02:38 +0000 Subject: [PATCH] Morph page updates instead of replacing them Co-Authored-By: Claude Opus 5.5 --- game.py | 1 + static/app.js | 54 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/game.py b/game.py index 42058f0..5b3b0c6 100644 --- a/game.py +++ b/game.py @@ -6753,6 +6753,7 @@ ORDER_RECIPES, ORDER_RAW = _order_sources() # line here (new / balance / fix), and a new release a new entry on top. PATCH_NOTES = [ {"date": "2026-10-28", "title": "Queue", "notes": [ + ("fix", "The page no longer redraws itself when an action finishes or a poll lands: scroll, hovers and tooltips stay put."), ("fix", "Crafting cards: buttons stay on one row on phones and narrow cards."), ("new", "\"Out of:\" lists every missing reagent."), ("new", "Updated guild roster layout"), diff --git a/static/app.js b/static/app.js index 77d6209..2711037 100644 --- a/static/app.js +++ b/static/app.js @@ -680,14 +680,58 @@ function render() { animate(); } -// Only touch the DOM when the markup changed. Replacing an element mid-swipe -// cancels the swipe on iOS, and polls arrive every few seconds. +// Only touch the DOM when the markup changed, and then only the nodes that did: +// swapping the whole page flickers, drops hovers and scroll, and replacing an +// element mid-swipe cancels the swipe on iOS. Polls arrive every few seconds. function setHtml(el, html) { if (el.dataset.html === html) return; - const scroll = el.scrollTop; - el.innerHTML = html; + const next = document.createElement("template"); + next.innerHTML = html; + morphChildren(el, next.content); el.dataset.html = html; - el.scrollTop = scroll; +} + +// Patch `from`'s children into `to`'s, pairing them up by position. +function morphChildren(from, to) { + let old = from.firstChild, fresh = to.firstChild; + while (fresh) { + const after = fresh.nextSibling; + if (!old) from.appendChild(fresh); + else if (old.nodeType === fresh.nodeType && old.nodeName === fresh.nodeName) { + morphNode(old, fresh); + old = old.nextSibling; + } else { + const gone = old; + old = old.nextSibling; + from.replaceChild(fresh, gone); + } + fresh = after; + } + while (old) { + const gone = old; + old = old.nextSibling; + gone.remove(); + } +} + +function morphNode(old, fresh) { + if (old.nodeType !== Node.ELEMENT_NODE) { + if (old.nodeValue !== fresh.nodeValue) old.nodeValue = fresh.nodeValue; + return; + } + // A fire's canvas sizes itself and holds its drawing: setting its size clears it. + if (old.nodeName === "CANVAS" && old.dataset.flame === fresh.dataset.flame) return; + for (const { name } of [...old.attributes]) if (!fresh.hasAttribute(name)) old.removeAttribute(name); + for (const { name, value } of fresh.attributes) if (old.getAttribute(name) !== value) old.setAttribute(name, value); + morphChildren(old, fresh); + // What's typed or picked lives in properties, not attributes. Kept fields keep theirs (see render). + if (old.dataset.keep === undefined) { + if (old.nodeName === "INPUT") { + old.checked = fresh.checked; + if (old.value !== fresh.value) old.value = fresh.value; + } else if (old.nodeName === "TEXTAREA" && old.value !== fresh.value) old.value = fresh.value; + else if (old.nodeName === "SELECT") old.value = fresh.value; + } } function setMenu(open) {