fix(#4778): scroll-rebuild guard must not reclassify a pinned follower as unpinned

Codex regression gate caught: the guard predicate keyed on a raw messagesEl.scrollTop>0,
so a pinned reader transiently at bottomDistance>250 mid-render (assistant body grows
before the anchor scene re-renders) got flipped to _messageUserUnpinned=true/_scrollPinned=false
and lost auto-follow. Require an explicit non-follow signal (_messageUserUnpinned || _scrollPinned===false)
instead. Updated the existing guard test to a genuine unpinned reader + added a pinned-large-growth
regression. Co-authored-by: franksong2702
This commit is contained in:
nesquena-hermes committed 2026-06-23 16:17:01 +00:00
1 parent c6d633fd2c
commit f4af6d8419
3 files changed
+53 -4

No files matched your search

+6 -1
View File
@@ -9595,7 +9595,12 @@ function _prepareLiveAnchorScrollRebuildGuard(scrollSnapshot){
const messagesEl=$('messages');
if(!messagesEl||!scrollSnapshot) return {readerAwayFromBottom:false,release:null};
const beforeBottomDistance=Math.max(0,messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight);
const readerAwayFromBottom=beforeBottomDistance>250&&(_messageUserUnpinned||messagesEl.scrollTop>0);
// Only treat the reader as away if they were ALREADY in a non-follow state.
// A pinned follower can transiently have bottomDistance>250 mid-render (the
// assistant body grows before the anchor scene re-renders), so keying on a
// raw scrollTop>0 here would mis-classify a pinned reader as unpinned and kill
// auto-follow. Require an explicit unpin/non-pinned signal instead.
const readerAwayFromBottom=beforeBottomDistance>250&&(_messageUserUnpinned||_scrollPinned===false);
if(!readerAwayFromBottom) return {readerAwayFromBottom:false,release:null};
scrollSnapshot.pinned=false;
scrollSnapshot.userUnpinned=true;
@@ -85,7 +85,7 @@ def test_live_anchor_worklog_rebuild_guards_height_for_unpinned_reader():
compact = _compact(guard)
assert "constbeforeBottomDistance=Math.max(0,messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight);" in compact
assert "beforeBottomDistance>250&&(_messageUserUnpinned||messagesEl.scrollTop>0)" in compact
assert "beforeBottomDistance>250&&(_messageUserUnpinned||_scrollPinned===false)" in compact
assert "scrollSnapshot.pinned=false;" in compact
assert "scrollSnapshot.userUnpinned=true;" in compact
assert "scrollSnapshot.bottom=beforeBottomDistance;" in compact
@@ -112,8 +112,8 @@ def test_live_anchor_rebuild_guard_holds_height_and_marks_reader_unpinned():
script = f"""
const assert = require('assert');
let _messageUserUnpinned = false;
let _scrollPinned = true;
let _messageUserUnpinned = true;
let _scrollPinned = false;
let _nearBottomCount = 2;
const messages = {{
scrollTop: 2000,
@@ -165,6 +165,50 @@ assert.strictEqual(Object.prototype.hasOwnProperty.call(inner.dataset, 'liveAnch
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_live_anchor_rebuild_guard_keeps_pinned_follower_pinned_on_large_growth():
"""A PINNED follower must NOT be reclassified as unpinned when a large live
render transiently pushes bottomDistance>250. Regression guard: the predicate
must require an explicit non-follow signal (_messageUserUnpinned / _scrollPinned
=== false), not a raw scrollTop>0, or pinned live streams stop auto-following."""
script = f"""
const assert = require('assert');
let _messageUserUnpinned = false;
let _scrollPinned = true;
let _nearBottomCount = 2;
const messages = {{
scrollTop: 4400,
scrollHeight: 5500,
clientHeight: 600,
}};
const inner = {{ style: {{ minHeight: '' }}, dataset: {{}} }};
function $(id) {{
if (id === 'messages') return messages;
if (id === 'msgInner') return inner;
return null;
}}
{_function_body(UI_JS, "_prepareLiveAnchorScrollRebuildGuard")}
const snapshot = {{
top: 4400,
bottom: 0,
scrollHeight: 5500,
pinned: true,
userUnpinned: false,
}};
const guard = _prepareLiveAnchorScrollRebuildGuard(snapshot);
// bottomDistance = 5500 - 4400 - 600 = 500 (>250), but the reader is pinned and
// has not manually unpinned -> must stay pinned, follow-scroll must NOT be skipped.
assert.strictEqual(guard.readerAwayFromBottom, false);
assert.strictEqual(guard.release, null);
assert.strictEqual(_messageUserUnpinned, false);
assert.strictEqual(_scrollPinned, true);
assert.strictEqual(snapshot.pinned, true);
assert.strictEqual(snapshot.userUnpinned, false);
assert.strictEqual(inner.style.minHeight, '');
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_restore_message_scroll_snapshot_keeps_intermediate_distance_state():
script = f"""
const assert = require('assert');