fix(stream): adopt parser-owned fade row instead of recloning it

After rewind/cache-miss the keyed visible row stayed distinct from the
persistent parser candidate, so every later growth frame deep-cloned the
parser DOM into the old row. That cut off the previous tail word's
~620ms fade and broke word-node identity.

Promote the parser-owned candidate into the existing row's DOM position
once, mute the already-rendered prefix on the real parser body, and
return the adopted node so later keyed renders hit existing === node.

Regression: two post-transition growth frames keep the first new tail
span as the same .is-new node until an explicit animationend.
This commit is contained in:
Anthony Ruiz committed 2026-08-27 23:58:14 +00:00
1 parent 9388c6495b
commit 69e4fa3b98
2 files changed
+275 -42

No files matched your search

+34 -20
View File
@@ -13859,6 +13859,8 @@ function _appendTransparentFadeText(body, text){
}
function _refreshTransparentFadeProseRow(existing, node, preservedState){
if(!existing || !node) return node || existing;
if(existing === node) return existing;
let body = existing.querySelector ? existing.querySelector('.msg-body') : null;
const nextText = String((node.dataset && node.dataset.rawText) || (node.textContent || ''));
// Resume strictly from the source-space cursor. `body.textContent` is NOT a
@@ -13870,6 +13872,37 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){
// guessing a delta.
const hasCursor = !!(existing.getAttribute && existing.getAttribute('data-stream-fade-text') !== null);
const currentText = hasCursor ? String(existing.getAttribute('data-stream-fade-text') || '') : '';
const candidateBody = node.querySelector ? node.querySelector('.msg-body') : null;
const parserOwned = !!(candidateBody && candidateBody !== body && candidateBody.__smdParser);
const mute = (typeof window !== 'undefined' && typeof window.__streamFadeMuteRenderedPrefix === 'function')
? window.__streamFadeMuteRenderedPrefix
: null;
if(parserOwned){
// Promote the actual parser-owned candidate into the keyed row's DOM
// position once. Cloning its children into the old visible row on every
// later growth frame cuts off the previous tail word's ~620ms fade
// (`.is-new` is stripped from the replacement) and breaks word-node
// identity used for scroll-anchor stability. After this swap, later
// keyed renders hit `_refreshTransparentLiveRow(existing === node)` and
// keep growing the same parser target. Pending and MEDIA tails stay on
// the parser until it flushes them.
const prevRendered = String((body && body.textContent) || '');
if(candidateBody.classList) candidateBody.classList.add('stream-fade-active');
_bindTransparentFadeCleanup(candidateBody);
if(mute && prevRendered) mute(candidateBody, prevRendered);
if(node.removeAttribute) node.removeAttribute('data-stream-fade-text');
const parent = existing.parentNode || existing.parentElement || null;
if(parent && existing.parentNode === parent){
if(typeof parent.replaceChild === 'function'){
parent.replaceChild(node, existing);
}else if(typeof parent.insertBefore === 'function'){
parent.insertBefore(node, existing);
if(typeof existing.remove === 'function') existing.remove();
}
}
_rehydrateTransparentLiveRow(node, existing, preservedState);
return node;
}
const pairs = _transparentLiveRowAttributePairs(node);
const kept = Object.create(null);
for(const pair of pairs){
@@ -13890,26 +13923,7 @@ function _refreshTransparentFadeProseRow(existing, node, preservedState){
existing.appendChild(body);
}
if(body.classList) body.classList.add('stream-fade-active');
const candidateBody = node.querySelector ? node.querySelector('.msg-body') : null;
const parserOwned = !!(candidateBody && candidateBody !== body && candidateBody.__smdParser);
const mute = (typeof window !== 'undefined' && typeof window.__streamFadeMuteRenderedPrefix === 'function')
? window.__streamFadeMuteRenderedPrefix
: null;
if(parserOwned && typeof candidateBody.cloneNode === 'function'){
// One authority after the first parser-owned rebuild: keep adopting the
// live parsed DOM. Cloning once and then appending later source bytes
// through `_appendTransparentFadeText` turns `Hello **` / `Hello **world**`
// into literal `**world**` while the candidate already has
// `<strong>world</strong>`. Pending and MEDIA tails stay on the parser
// until it flushes them into this candidate.
const prevRendered = String(body.textContent || '');
const clone = candidateBody.cloneNode(true);
body.textContent = '';
while(clone.childNodes && clone.childNodes.length) body.appendChild(clone.childNodes[0]);
_bindTransparentFadeCleanup(body);
if(mute && prevRendered) mute(body, prevRendered);
if(existing.removeAttribute) existing.removeAttribute('data-stream-fade-text');
}else if(!hasCursor || !nextText.startsWith(currentText)){
if(!hasCursor || !nextText.startsWith(currentText)){
// Rebuild branch. Snapshot the rendered text BEFORE clearing (#7082
// review should-fix): without it every word re-wraps as `.is-new`, the
// whole visible row dips to opacity 0 and fades back (~620ms), and the
+241 -22
View File
@@ -110,6 +110,7 @@ class FakeElement {
this.type = '';
this.disabled = false;
this._classes = new Set();
this._listeners = Object.create(null);
const self = this;
this.classList = {
add(...n){ n.forEach(x=>self._classes.add(x)); },
@@ -139,11 +140,46 @@ class FakeElement {
if(n.parentNode){ const i = n.parentNode.childNodes.indexOf(n); if(i >= 0) n.parentNode.childNodes.splice(i, 1); }
n.parentNode = this; this.childNodes.push(n); return n;
}
insertBefore(n, ref){
if(n && n._isFragment){ n.childNodes.slice().forEach(c=>this.insertBefore(c, ref)); return n; }
if(n && n.parentNode){ const i = n.parentNode.childNodes.indexOf(n); if(i >= 0) n.parentNode.childNodes.splice(i, 1); }
if(n) n.parentNode = this;
if(!ref){ this.childNodes.push(n); return n; }
const i = this.childNodes.indexOf(ref);
if(i < 0) this.childNodes.push(n);
else this.childNodes.splice(i, 0, n);
return n;
}
replaceChild(n, old){
const i = this.childNodes.indexOf(old);
if(i < 0) return old;
if(n && n.parentNode){ const j = n.parentNode.childNodes.indexOf(n); if(j >= 0) n.parentNode.childNodes.splice(j, 1); }
if(old) old.parentNode = null;
if(n) n.parentNode = this;
this.childNodes[i] = n;
return old;
}
remove(){
if(!this.parentNode) return;
const i = this.parentNode.childNodes.indexOf(this);
if(i >= 0) this.parentNode.childNodes.splice(i, 1);
this.parentNode = null;
}
setAttribute(name, value){ this.attributes[String(name)] = String(value ?? ''); }
getAttribute(name){ const k = String(name); return k in this.attributes ? this.attributes[k] : null; }
removeAttribute(name){ delete this.attributes[String(name)]; }
getAttributeNames(){ return Object.keys(this.attributes); }
addEventListener(){}
addEventListener(type, fn){
const key = String(type || '');
if(!this._listeners[key]) this._listeners[key] = [];
this._listeners[key].push(fn);
}
dispatchEvent(event){
const type = event && event.type ? String(event.type) : '';
const list = this._listeners[type] || [];
for(const fn of list) fn(event);
return true;
}
cloneNode(deep){
const copy = new FakeElement(this.tagName);
copy._classes = new Set(this._classes);
@@ -153,11 +189,19 @@ class FakeElement {
return copy;
}
querySelector(sel){
const want = String(sel).split(',')[0].trim().replace(/^\./, '');
const options = String(sel || '').split(',').map(s => s.trim()).filter(Boolean);
const matches = (el, option)=>{
const classes = [];
const re = /\.([A-Za-z0-9_-]+)/g;
let m;
while((m = re.exec(option))) classes.push(m[1]);
if(!classes.length) return false;
return classes.every(cls => el._classes && el._classes.has(cls));
};
const walk = (node)=>{
for(const c of node.childNodes){
if(c.nodeType !== 1) continue;
if(c._classes.has(want)) return c;
if(options.some(opt => matches(c, opt))) return c;
const hit = walk(c);
if(hit) return hit;
}
@@ -371,6 +415,7 @@ const messagesSrc = readFileSync(process.env.MESSAGES_JS_PATH, 'utf8');
extractFunc('_transparentLiveRowInteractiveState'),
extractFunc('_rehydrateTransparentLiveRow'),
extractFunc('_refreshTransparentFadeProseRow'),
extractFunc('_refreshTransparentLiveRow'),
].join('\\n'));
(0, eval)(extractFuncFrom(messagesSrc, '_streamFadeMuteRenderedPrefix'));
globalThis.window.__streamFadeMuteRenderedPrefix = globalThis._streamFadeMuteRenderedPrefix;
@@ -390,13 +435,14 @@ function makeRow(rawText){
return row;
}
function collectFadeSpans(root){
return collectFadeSpanNodes(root).map(c=>({ text: c.textContent, isNew: c._classes.has('is-new') }));
}
function collectFadeSpanNodes(root){
const out = [];
const walk = (node)=>{
for(const c of node.childNodes || []){
if(c.nodeType !== 1) continue;
if(c._classes && c._classes.has('stream-fade-word')){
out.push({ text: c.textContent, isNew: c._classes.has('is-new') });
}
if(c._classes && c._classes.has('stream-fade-word')) out.push(c);
walk(c);
}
};
@@ -525,9 +571,9 @@ candidateBody.__smdParser = candidateParser;
const candidateRendered = candidateBody.textContent;
const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || '');
_refreshTransparentFadeProseRow(existing, candidate, null);
const visible = _refreshTransparentFadeProseRow(existing, candidate, null);
const refreshedBody = existing.querySelector('.msg-body');
const refreshedBody = visible.querySelector('.msg-body');
const link = findTag(refreshedBody, 'A');
const strong = findTag(refreshedBody, 'STRONG');
process.stdout.write(JSON.stringify({
@@ -540,7 +586,9 @@ process.stdout.write(JSON.stringify({
linkHref: link ? link.getAttribute('href') : null,
hasStrong: !!strong,
strongText: strong ? strong.textContent : null,
cursor: existing.getAttribute('data-stream-fade-text'),
cursor: visible.getAttribute('data-stream-fade-text'),
adoptedParserRow: visible === candidate,
sameParserBody: refreshedBody === candidateBody,
}));
"""
% json.dumps(md_source)
@@ -562,6 +610,8 @@ process.stdout.write(JSON.stringify({
# parser until it flushes them.
assert data["pendingTail"] == "."
assert data["cursor"] is None
assert data["adoptedParserRow"] is True
assert data["sameParserBody"] is True
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
@@ -595,18 +645,18 @@ candidateBody.__smdParser = candidateParser;
const pendingTail = String(candidateParser.pending || '') + String(candidateParser.text || '');
const candidateRendered = candidateBody.textContent;
_refreshTransparentFadeProseRow(existing, candidate, null);
const afterRebuildText = existing.querySelector('.msg-body').textContent;
const afterRebuildCursor = existing.getAttribute('data-stream-fade-text');
const visible = _refreshTransparentFadeProseRow(existing, candidate, null);
const afterRebuildText = visible.querySelector('.msg-body').textContent;
const afterRebuildCursor = visible.getAttribute('data-stream-fade-text');
_refreshTransparentFadeProseRow(existing, candidate, null);
const afterResumeBody = existing.querySelector('.msg-body');
const sameVisible = _refreshTransparentFadeProseRow(visible, candidate, null);
const afterResumeBody = sameVisible.querySelector('.msg-body');
const afterResumeStrong = findTag(afterResumeBody, 'STRONG');
const afterResumeLink = findTag(afterResumeBody, 'A');
if(typeof smd.parser_end === 'function') smd.parser_end(candidateParser);
_refreshTransparentFadeProseRow(existing, candidate, null);
const afterEndBody = existing.querySelector('.msg-body');
const settledVisible = _refreshTransparentFadeProseRow(sameVisible, candidate, null);
const afterEndBody = settledVisible.querySelector('.msg-body');
process.stdout.write(JSON.stringify({
pendingTail,
@@ -614,12 +664,16 @@ process.stdout.write(JSON.stringify({
afterRebuildText,
afterRebuildCursor,
afterResumeText: afterResumeBody.textContent,
afterResumeCursor: existing.getAttribute('data-stream-fade-text'),
afterResumeCursor: sameVisible.getAttribute('data-stream-fade-text'),
hasStrongAfterResume: !!afterResumeStrong,
hasLinkAfterResume: !!afterResumeLink,
literalStarsAfterResume: afterResumeBody.textContent.includes('**'),
afterEndText: afterEndBody.textContent,
literalStarsAfterEnd: afterEndBody.textContent.includes('**'),
adoptedParserRow: visible === candidate,
laterRefreshSameRow: sameVisible === candidate,
settledSameRow: settledVisible === candidate,
sameParserBody: afterResumeBody === candidateBody,
}));
"""
% json.dumps(md_source)
@@ -634,6 +688,10 @@ process.stdout.write(JSON.stringify({
# Same live parser: later refreshes must keep parsed structure. The
# parser may flush its held-back "." into the candidate; that is still
# parser-owned text, not a source-byte append.
assert data["adoptedParserRow"] is True
assert data["laterRefreshSameRow"] is True
assert data["settledSameRow"] is True
assert data["sameParserBody"] is True
assert data["afterResumeCursor"] is None
assert data["hasStrongAfterResume"] is True
assert data["hasLinkAfterResume"] is True
@@ -697,21 +755,21 @@ const parser = smd.parser(smd.default_renderer(candidateBody));
smd.parser_write(parser, FIRST);
candidateBody.__smdParser = parser;
_refreshTransparentFadeProseRow(existing, candidate, null);
const visible = _refreshTransparentFadeProseRow(existing, candidate, null);
smd.parser_write(parser, FULL.slice(FIRST.length));
candidate.dataset.rawText = FULL;
_refreshTransparentFadeProseRow(existing, candidate, null);
const sameVisible = _refreshTransparentFadeProseRow(visible, candidate, null);
const liveAfterClose = existing.querySelector('.msg-body');
const liveAfterClose = sameVisible.querySelector('.msg-body');
const strongAfterClose = findTag(liveAfterClose, 'STRONG');
const htmlAfterClose = serialize(liveAfterClose);
const textAfterClose = liveAfterClose.textContent;
const candidateHtmlAfterClose = serialize(candidateBody);
if(typeof smd.parser_end === 'function') smd.parser_end(parser);
_refreshTransparentFadeProseRow(existing, candidate, null);
const liveAfterEnd = existing.querySelector('.msg-body');
const settledVisible = _refreshTransparentFadeProseRow(sameVisible, candidate, null);
const liveAfterEnd = settledVisible.querySelector('.msg-body');
const strongAfterEnd = findTag(liveAfterEnd, 'STRONG');
process.stdout.write(JSON.stringify({
@@ -733,6 +791,10 @@ process.stdout.write(JSON.stringify({
helloCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'Hello'),
worldCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'world'),
doneCountAfterEnd: countSubstr(liveAfterEnd.textContent, 'done'),
adoptedParserRow: visible === candidate,
laterRefreshSameRow: sameVisible === candidate,
settledSameRow: settledVisible === candidate,
sameParserBody: liveAfterClose === candidateBody,
}));
"""
% (json.dumps(first_source), json.dumps(full_source))
@@ -760,3 +822,160 @@ process.stdout.write(JSON.stringify({
assert data["helloCountAfterEnd"] == 1
assert data["worldCountAfterEnd"] == 1
assert data["doneCountAfterEnd"] == 1
assert data["adoptedParserRow"] is True
assert data["laterRefreshSameRow"] is True
assert data["settledSameRow"] is True
assert data["sameParserBody"] is True
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_live_prose_post_rewind_keeps_tail_word_identity_across_two_growths():
"""#7082 must-fix: after the separate-owner rewind transition, two later
growth frames must keep the first genuinely-new tail span as the same
node with ``.is-new`` until an explicit ``animationend``.
"""
prefix_source = "Hello there"
first_growth = "Hello there world"
second_growth = "Hello there world more"
script = (
_HARNESS
+ _REBUILD_PRELUDE
+ """
const smd = await import(process.env.SMD_PATH);
const PREFIX = %s;
const FIRST = %s;
const SECOND = %s;
function wrapWords(parent, text){
const words = String(text || '').split(' ');
words.forEach((word, i)=>{
const span = new FakeElement('span');
span.className = 'stream-fade-word is-new';
span.textContent = word;
parent.appendChild(span);
if(i < words.length - 1) parent.appendChild(new TextNode(' '));
});
}
const host = new FakeElement('div');
const existing = makeRow(undefined);
delete existing.dataset.rawText;
const staleBody = existing.querySelector('.msg-body');
wrapWords(staleBody, PREFIX);
host.appendChild(existing);
const candidate = makeRow(PREFIX);
const candidateBody = candidate.querySelector('.msg-body');
const parser = smd.parser(smd.default_renderer(candidateBody));
smd.parser_write(parser, PREFIX);
candidateBody.__smdParser = parser;
const pendingAtAdopt = String(parser.pending || '') + String(parser.text || '');
// Keep the live parser identity, then rebuild fade spans so the mute + later
// growth can be observed as node identity. Do not parser_write after this:
// the default renderer would replace the word nodes we need to pin.
candidateBody.textContent = '';
const p = new FakeElement('p');
const strong = new FakeElement('strong');
const hello = new FakeElement('span');
hello.className = 'stream-fade-word is-new';
hello.textContent = 'Hello';
const there = new FakeElement('span');
there.className = 'stream-fade-word is-new';
there.textContent = 'there';
strong.appendChild(there);
p.appendChild(hello);
p.appendChild(new TextNode(' '));
p.appendChild(strong);
candidateBody.appendChild(p);
const visible = _refreshTransparentFadeProseRow(existing, candidate, null);
const afterAdoptNew = collectFadeSpanNodes(visible).filter(span => span._classes.has('is-new'));
candidate.dataset.rawText = FIRST;
const world = new FakeElement('span');
world.className = 'stream-fade-word is-new';
world.textContent = 'world';
p.appendChild(new TextNode(' '));
p.appendChild(world);
const afterFirstGrowth = _refreshTransparentLiveRow(visible, candidate, null);
const firstNewWasAnimated = world._classes.has('is-new');
const firstNewAfterFirstGrowth = world;
candidate.dataset.rawText = SECOND;
const more = new FakeElement('span');
more.className = 'stream-fade-word is-new';
more.textContent = 'more';
p.appendChild(new TextNode(' '));
p.appendChild(more);
const afterSecondGrowth = _refreshTransparentLiveRow(afterFirstGrowth, candidate, null);
const afterSecondSpans = collectFadeSpanNodes(afterSecondGrowth);
const afterSecondNew = afterSecondSpans.filter(span => span._classes.has('is-new'));
const secondNewSameIdentity = world === firstNewAfterFirstGrowth && afterSecondSpans.includes(world);
const secondNewIsNew = world._classes.has('is-new');
visible.querySelector('.msg-body').dispatchEvent({ type: 'animationend', target: world });
const afterCleanupNew = collectFadeSpanNodes(afterSecondGrowth).filter(span => span._classes.has('is-new'));
if(typeof smd.parser_end === 'function') smd.parser_end(parser);
const settled = _refreshTransparentLiveRow(afterSecondGrowth, candidate, null);
const settledBody = settled.querySelector('.msg-body');
process.stdout.write(JSON.stringify({
adoptedParserRow: visible === candidate,
firstGrowthSameRow: afterFirstGrowth === candidate,
secondGrowthSameRow: afterSecondGrowth === candidate,
sameParserBody: afterSecondGrowth.querySelector('.msg-body') === candidateBody,
afterAdoptNewTexts: afterAdoptNew.map(span => span.textContent),
firstNewText: firstNewAfterFirstGrowth.textContent,
firstNewWasAnimated,
firstNewStillConnected: !!(world.parentNode),
secondNewSameIdentity,
secondNewIsNew,
afterSecondNewTexts: afterSecondNew.map(span => span.textContent),
afterCleanupNewTexts: afterCleanupNew.map(span => span.textContent),
hasStrong: !!findTag(afterSecondGrowth, 'STRONG'),
strongText: findTag(afterSecondGrowth, 'STRONG') ? findTag(afterSecondGrowth, 'STRONG').textContent : null,
literalStars: afterSecondGrowth.textContent.includes('**'),
pendingAtAdopt,
settledSameRow: settled === candidate,
afterSecondText: afterSecondGrowth.querySelector('.msg-body').textContent,
afterEndText: settledBody.textContent,
cursorAfterAdopt: visible.getAttribute('data-stream-fade-text'),
cursorAfterSecond: afterSecondGrowth.getAttribute('data-stream-fade-text'),
oldRowDetached: existing.parentNode === null,
adoptedRowParented: visible.parentNode === host,
helloCount: (settledBody.textContent.match(/Hello/g) || []).length,
worldCount: (settledBody.textContent.match(/world/g) || []).length,
moreCount: (settledBody.textContent.match(/more/g) || []).length,
}));
"""
% (json.dumps(prefix_source), json.dumps(first_growth), json.dumps(second_growth))
)
data = _run_node_module(script)
assert data["adoptedParserRow"] is True
assert data["firstGrowthSameRow"] is True
assert data["secondGrowthSameRow"] is True
assert data["sameParserBody"] is True
assert data["afterAdoptNewTexts"] == []
assert data["firstNewText"] == "world"
assert data["firstNewWasAnimated"] is True
assert data["firstNewStillConnected"] is True
assert data["secondNewSameIdentity"] is True
assert data["secondNewIsNew"] is True
assert data["afterSecondNewTexts"] == ["world", "more"]
assert data["afterCleanupNewTexts"] == ["more"]
assert data["hasStrong"] is True
assert data["strongText"] == "there"
assert data["literalStars"] is False
assert data["settledSameRow"] is True
assert data["afterSecondText"] == second_growth
assert data["afterEndText"] == second_growth
assert data["cursorAfterAdopt"] is None
assert data["cursorAfterSecond"] is None
assert data["oldRowDetached"] is True
assert data["adoptedRowParented"] is True
assert data["helloCount"] == 1
assert data["worldCount"] == 1
assert data["moreCount"] == 1