Morph page updates instead of replacing them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-28 23:02:38 +00:00
parent 377f534f6b
commit 3df9f29b1d
2 changed files with 50 additions and 5 deletions

View file

@ -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"),

View file

@ -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) {