fix: preserve ownership of live scroll snapshots

This commit is contained in:
Frank Song committed 2026-07-31 22:24:25 +08:00
1 parent 5c3ead61a2
commit 5efc94f4b2
2 files changed
+58 -3

No files matched your search

+37 -1
View File
@@ -961,6 +961,7 @@ function _captureMessageViewportAnchor(){
// anchor's topOffset is stale and realigning to it would yank a still reader
// backward (issue #5637).
scrollHeightAtCapture:container.scrollHeight,
inputGeneration:typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : 0,
};
}
}
@@ -5680,6 +5681,9 @@ let _lastMessageClientHeight=null; // #4702: track scroller height to ignore i
// window after load as suppressed.
let _lastNonMessageScrollIntentMs=-Infinity;
let _messageUserUnpinned=false;
// A monotonic ownership token lets delayed restores distinguish reader input
// that happened after a snapshot from input that merely happened recently.
let _messageScrollInputGeneration=0;
let _bottomSettleToken=0;
let _settleRAF=0;
let _settleRO=null;
@@ -5763,6 +5767,9 @@ function _recordNonMessageScrollIntent(e){
const target=e&&e.target;
if(!el||!target) return;
if(!el.contains(target)){ _lastNonMessageScrollIntentMs=performance.now(); return; }
if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY!==0)){
_messageScrollInputGeneration++;
}
// #4970: record ANY upward message-pane wheel motion as recent wheel intent,
// including gentle low-delta trackpad wheels (e.g. deltaY:-5) that never reach
// the decisive -30 sticky-unpin threshold below. The post-render artifact
@@ -5966,7 +5973,10 @@ if(typeof window!=='undefined'){
const el=document.getElementById('messages');
if(!el) return;
el.addEventListener('pointerdown',(e)=>{
if(e.target===el&&e.offsetX>=el.clientWidth) _scrollbarDragActive=true;
if(e.target===el&&e.offsetX>=el.clientWidth){
_scrollbarDragActive=true;
_messageScrollInputGeneration++;
}
},{passive:true});
window.addEventListener('pointerup',()=>{
if(!_scrollbarDragActive) return;
@@ -6012,6 +6022,7 @@ if(typeof window!=='undefined'){
// contains the focus, or the pointer is over it (keyboard scroll w/o focus).
if(a===el||el.contains(a)||el.matches(':hover')){
const now=performance.now();
_messageScrollInputGeneration++;
_lastMessageKeyScrollIntentMs=now;
const bottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight;
if(bottomDistance>120) _lastMessageScrollIntentMs=now;
@@ -14985,10 +14996,24 @@ function _captureMessageScrollSnapshot(){
top:el.scrollTop,
bottom,
scrollHeight:el.scrollHeight,
inputGeneration:typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : 0,
pinned:readerAwayFromBottom?false:_shouldFollowMessagesOnDomReplace(),
userUnpinned:readerAwayFromBottom?true:_messageUserUnpinned,
};
}
function _messageScrollSnapshotInputChanged(snapshot){
if(!snapshot||snapshot.pinned===true) return false;
const captured=Number(snapshot.inputGeneration);
const current=typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : captured;
return Number.isFinite(captured)&&Number.isFinite(current)&&current!==captured;
}
function _abandonMessageScrollSnapshot(){
_lastScrollTop=$('messages')?.scrollTop||0;
_lastMessageClientHeight=$('messages')?.clientHeight||0;
_messageUserUnpinned=true;
_scrollPinned=false;
_nearBottomCount=0;
}
function _restorePinnedMessageScrollSnapshot(snapshot){
const el=$('messages');
if(!el||!snapshot||snapshot.pinned!==true||snapshot.userUnpinned===true) return false;
@@ -15016,6 +15041,10 @@ function _restoreMessageScrollSnapshot(snapshot){
// pinned streaming transcript upward. Semantic anchors remain for manual
// unpinned reading positions below.
if(_restorePinnedMessageScrollSnapshot(snapshot)) return;
if(_messageScrollSnapshotInputChanged(snapshot)){
_abandonMessageScrollSnapshot();
return;
}
let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function')
? _restoreMessageViewportAnchor(snapshot.anchor,0)
: false;
@@ -15234,6 +15263,13 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){
// streaming. Pinned followers must stay tail-relative here too; restoring the
// semantic viewport anchor is only safe for explicitly unpinned readers.
if(_restorePinnedMessageScrollSnapshot(snapshot)) return;
// A delayed rAF restore must not overwrite a position the reader changed
// after capture. Recent-intent timestamps are lossy; the generation is
// monotonic and therefore preserves snapshot ownership exactly.
if(_messageScrollSnapshotInputChanged(snapshot)){
_abandonMessageScrollSnapshot();
return;
}
let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function')
? _restoreMessageViewportAnchor(snapshot.anchor,0)
: false;
+21 -2
View File
@@ -177,7 +177,8 @@ def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top
init_scroll_top: int = 90030,
snapshot_user_unpinned: bool = False,
anchor=None, anchor_row_content_pos=None, container_top: int = 0,
top_pad_now=None) -> str:
top_pad_now=None, input_generation: int = 0,
delayed_input_generation=None) -> str:
"""Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame.
SCROLL-DEPENDENT geometry (round-3 gate-cert requirement): the anchor row's
@@ -203,6 +204,7 @@ def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top
return _extract_func_script(js) + f"""
let writes = [];
let stTop = {init_scroll_top};
let _messageScrollInputGeneration = {input_generation};
const CONTAINER_TOP = {container_top};
const ROW_CONTENT_POS = {row_pos_js};
const ROW_PRESENT = {"true" if row_present else "false"};
@@ -242,10 +244,13 @@ function _deferClearProgrammaticScroll(){{}}
function requestAnimationFrame(cb){{ cb(); }}
function setTimeout(cb){{ cb(); return 1; }}
const snapshot = {{ anchor: {anchor_js}, top: {snapshot_top}, bottom: 40,
scrollHeight: {sh}, pinned: false, userUnpinned: {str(snapshot_user_unpinned).lower()} }};
scrollHeight: {sh}, inputGeneration: {input_generation}, pinned: false, userUnpinned: {str(snapshot_user_unpinned).lower()} }};
eval(extractFunc('_desktopAnchorRealignDelta'));
eval(extractFunc('_messageScrollSnapshotInputChanged'));
eval(extractFunc('_abandonMessageScrollSnapshot'));
eval(extractFunc('_restoreMessageScrollSnapshotSameFrame'));
_restoreMessageScrollSnapshotSameFrame(snapshot);
{f"stTop = 89000; _messageScrollInputGeneration = {delayed_input_generation}; _restoreMessageScrollSnapshotSameFrame(snapshot);" if delayed_input_generation is not None else ""}
console.log(JSON.stringify({{ wrote: writes.length, writes,
messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, finalScrollTop: Math.round(stTop) }}));
"""
@@ -328,6 +333,20 @@ def test_unpinned_anchor_miss_restores_on_desktop_without_native_anchor():
assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False
def test_delayed_restore_cannot_overwrite_new_desktop_input():
"""The synchronous restore owns generation 0, then the reader moves to 89000
before the delayed rAF restore. The second call must abandon the stale snapshot
instead of writing its old 89577 target."""
m = json.loads(_run_node(_fallback_harness(
snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
active_intent=False, touch_like=False, snapshot_user_unpinned=True,
init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
)))
assert m["writes"] == [89577]
assert m["finalScrollTop"] == 89000
assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False
# ---- round-3 desktop anchor-realign (PR #5742): app's own scrollTop += delta idiom -----
# These use SCROLL-DEPENDENT rect mocks (rect.top = rowContentPos - scrollTop), so the
# certified hold numbers are physically realizable in a real browser — the round-2