← back to Crazy News Channel Shadowman
Keyed child morph + failed-image memory: stop cascading card rebuilds
30c507727a86dcab69b67763e95e8e6336965f00 · 2026-09-23 20:25:04 -0700 · Steve Abrams
Cody gate on 43aba19 was right: morphNode matched children by position, so
once a card's photo 404'd (onerror removed the <img>) every later sibling
was off by one, each tag mismatch triggered replaceWith, and the whole card
was rebuilt -- and the dead <img> was re-added and re-fetched. Reproduced on
43aba19: 0/20 descendants survived a stage change, link detached, image back.
- morphNode now matches children by tag + first class (forward search), so
an optional child appearing/disappearing doesn't shift siblings.
- imageFailed() records 404'd srcs; card and article templates omit them,
so the card matches its template again instead of fighting it.
- patchStoryGrid compares with isEqualNode (morph can reorder attributes,
which made outerHTML differ forever and re-morph every tick).
After: 19/19 descendants survive, link kept, image fetched once. Fuzz: 150
random mutations (stage bumps, creates, deletes, image failure, 12 lead
changes), 4888 card checks vs fresh render, 0 mismatches. All prior
regressions pass (cards destroyed 0/30s, clicks 30/30, Esc race, focus
return, overflow 4 widths x Chrome/WebKit, hash, Cody 4/4, arcs, escaping,
standby, reduced motion, WebKit smoke).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH
Files touched
Diff
commit 30c507727a86dcab69b67763e95e8e6336965f00
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 20:25:04 2026 -0700
Keyed child morph + failed-image memory: stop cascading card rebuilds
Cody gate on 43aba19 was right: morphNode matched children by position, so
once a card's photo 404'd (onerror removed the <img>) every later sibling
was off by one, each tag mismatch triggered replaceWith, and the whole card
was rebuilt -- and the dead <img> was re-added and re-fetched. Reproduced on
43aba19: 0/20 descendants survived a stage change, link detached, image back.
- morphNode now matches children by tag + first class (forward search), so
an optional child appearing/disappearing doesn't shift siblings.
- imageFailed() records 404'd srcs; card and article templates omit them,
so the card matches its template again instead of fighting it.
- patchStoryGrid compares with isEqualNode (morph can reorder attributes,
which made outerHTML differ forever and re-morph every tick).
After: 19/19 descendants survive, link kept, image fetched once. Fuzz: 150
random mutations (stage bumps, creates, deletes, image failure, 12 lead
changes), 4888 card checks vs fresh render, 0 mismatches. All prior
regressions pass (cards destroyed 0/30s, clicks 30/30, Esc race, focus
return, overflow 4 widths x Chrome/WebKit, hash, Cody 4/4, arcs, escaping,
standby, reduced motion, WebKit smoke).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH
---
index.html | 50 ++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 42 insertions(+), 8 deletions(-)
diff --git a/index.html b/index.html
index 7f0af23..f7e247c 100644
--- a/index.html
+++ b/index.html
@@ -1405,7 +1405,7 @@ function storyCardHTML(story, isLead) {
<article class="story-card${isLead ? " lead" : ""}${concluded ? " concluded" : ""}${justAdded ? " just-added" : ""}"
data-id="${escapeHTML(story.id)}"
aria-label="${isLead ? "Lead story" : "Story"}: ${escapeHTML(story.categoryLabel)}">
- ${story.image ? `<img class="story-photo" src="${escapeHTML(story.image)}" alt="" width="800" height="457" ${isLead ? 'fetchpriority="high"' : 'loading="lazy"'} onerror="this.remove()">` : ""}
+ ${story.image && !FAILED_IMAGES.has(story.image) ? `<img class="story-photo" src="${escapeHTML(story.image)}" alt="" width="800" height="457" ${isLead ? 'fetchpriority="high"' : 'loading="lazy"'} onerror="imageFailed(this)">` : ""}
<div class="kicker">
<span class="badge-cat">${escapeHTML(story.categoryLabel)}</span>
${isLead ? '<span class="badge-lead">Lead Story</span>' : ""}
@@ -1447,6 +1447,23 @@ function escapeHTML(str) {
// thrown back to <body> (screenrecord run3, both passes). Now only cards whose
// markup actually changed are replaced, unchanged nodes are kept, and focus is
// restored if the focused card was one of the replaced ones.
+// Images that 404'd once are left out of later renders, so a card whose photo
+// failed matches its template again instead of re-adding (and re-fetching)
+// the dead <img> every time it changes.
+const FAILED_IMAGES = new Set();
+function imageFailed(img) {
+ FAILED_IMAGES.add(img.getAttribute("src"));
+ img.remove();
+}
+
+// Children are matched by tag + first class, not position, so an optional
+// child (a removed photo, the lead badge, the concluded ribbon) appearing or
+// disappearing doesn't shift every later sibling into a tag mismatch.
+function morphKey(n) {
+ if (n.nodeType !== 1) return "#" + n.nodeType;
+ return n.nodeName + "." + ((n.getAttribute("class") || "").split(/\s+/)[0] || "");
+}
+
// Bring `node` in line with `fresh` in place: sync attributes, update text,
// recurse into children by position, and only swap a subtree when the tag
// itself differs. A card that just escalated keeps its <article> and <a>
@@ -1464,12 +1481,28 @@ function morphNode(node, fresh) {
[...node.attributes].forEach((a) => { if (!fresh.hasAttribute(a.name)) node.removeAttribute(a.name); });
[...fresh.attributes].forEach((a) => { if (node.getAttribute(a.name) !== a.value) node.setAttribute(a.name, a.value); });
const oldKids = [...node.childNodes];
- const newKids = [...fresh.childNodes];
- newKids.forEach((kid, i) => {
- if (i < oldKids.length) morphNode(oldKids[i], kid);
- else node.appendChild(kid);
+ const used = new Set();
+ let cursor = 0;
+ [...fresh.childNodes].forEach((kid, i) => {
+ const key = morphKey(kid);
+ let j = -1;
+ for (let k = cursor; k < oldKids.length; k++) {
+ if (!used.has(k) && morphKey(oldKids[k]) === key) { j = k; break; }
+ }
+ let target;
+ if (j === -1) {
+ target = kid;
+ } else {
+ used.add(j);
+ cursor = j + 1;
+ target = oldKids[j];
+ morphNode(target, kid);
+ if (!target.isConnected || target.parentNode !== node) target = null; // tag swap replaced it
+ }
+ const at = node.childNodes[i] || null;
+ if (target && at !== target) node.insertBefore(target, at);
});
- for (let i = newKids.length; i < oldKids.length; i++) oldKids[i].remove();
+ oldKids.forEach((k, idx) => { if (!used.has(idx) && k.parentNode === node) k.remove(); });
}
function patchStoryGrid(entries) {
@@ -1492,7 +1525,8 @@ function patchStoryGrid(entries) {
tpl.innerHTML = html.trim();
const fresh = tpl.content.firstElementChild;
if (!node) { grid.appendChild(fresh); node = fresh; }
- else if (node.outerHTML !== fresh.outerHTML) morphNode(node, fresh);
+ // isEqualNode ignores attribute order, which morphNode can change
+ else if (!node.isEqualNode(fresh)) morphNode(node, fresh);
if (grid.children[i] !== node) grid.insertBefore(node, grid.children[i] || null);
});
existing.forEach((el, id) => { if (!wanted.has(id)) el.remove(); });
@@ -2351,7 +2385,7 @@ function renderArticleContent(source) {
<span>${escapeHTML(source.publishedLabel || "Filed today")}</span>
</p>
<figure class="article-photo">
- ${source.image ? `<img src="${escapeHTML(source.image)}" alt="${escapeHTML(art.photoCaption || headline)}" width="800" height="457" onerror="this.remove()">` : photoSVG(source.id)}
+ ${source.image && !FAILED_IMAGES.has(source.image) ? `<img src="${escapeHTML(source.image)}" alt="${escapeHTML(art.photoCaption || headline)}" width="800" height="457" onerror="imageFailed(this)">` : photoSVG(source.id)}
<figcaption>${escapeHTML(art.photoCaption || "")}</figcaption>
</figure>
${isStory ? `<div id="liveUpdateBlock" class="live-update-block" aria-live="polite">${buildLiveUpdateInnerHTML(source)}</div>` : ""}
← 43aba19 Morph escalated cards in place; close articles synchronously
·
back to Crazy News Channel Shadowman
·
Reset to Defaults clears FAILED_IMAGES d583f26 →