fix: preserve ownership of live scroll snapshots
This commit is contained in:
1 parent
5c3ead61a2
commit
5efc94f4b2
2 files changed
+58
-3
No files matched your search
+37
-1
@@ -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)&¤t!==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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user