[object Object]

← back to Crazy News Channel

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  ·  Reset to Defaults clears FAILED_IMAGES d583f26 →