diff --git a/static/ui.js b/static/ui.js index 63535edc..b9b8871e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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; diff --git a/tests/test_issue5637_stale_anchor_guard.py b/tests/test_issue5637_stale_anchor_guard.py index e06e6c28..32c0055c 100644 --- a/tests/test_issue5637_stale_anchor_guard.py +++ b/tests/test_issue5637_stale_anchor_guard.py @@ -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