diff --git a/static/ui.js b/static/ui.js index eada54bb..63535edc 100644 --- a/static/ui.js +++ b/static/ui.js @@ -15245,6 +15245,10 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){ if(!restoredViaAnchor){ const maxTop=Math.max(0,el.scrollHeight-el.clientHeight); const bottom=Number(snapshot.bottom); + // Mobile/touch viewports have native overflow anchoring to hold an + // unpinned reader across a rebuild. Desktop deliberately disables that + // browser behavior, so it must continue into the explicit fallback below. + const _fbTouchHold=(typeof _isTouchLikeMessageViewport==='function' && _isTouchLikeMessageViewport(el)); // #5637: when the reader has scrolled UP into history (userUnpinned) and the // semantic anchor restore failed, do NOT snap scrollTop to the captured // ABSOLUTE snapshot.top. During streaming, the live activity-scene refresh @@ -15254,7 +15258,7 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){ // untouched lets the browser's own scroll anchoring hold the reader's // position. Pinned / near-bottom readers still get the tail-relative restore // below (that path is correct and must run). - if(snapshot.userUnpinned===true&&snapshot.pinned!==true){ + if(snapshot.userUnpinned===true&&snapshot.pinned!==true&&_fbTouchHold){ _lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight; _messageUserUnpinned=true; _scrollPinned=false; @@ -15285,7 +15289,6 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){ const _grewSinceSnap=Number.isFinite(_snapSH)&&_snapSH>0&&(el.scrollHeight-_snapSH)>4; const _fbActiveIntent=(typeof _recentMessageScrollIntent==='function' && _recentMessageScrollIntent()) || (typeof _recentMessageTouchScrollIntent==='function' && _recentMessageTouchScrollIntent()); - const _fbTouchHold=(typeof _isTouchLikeMessageViewport==='function' && _isTouchLikeMessageViewport(el)); if(_fbTouchHold && snapshot.pinned!==true && _grewSinceSnap && !_fbActiveIntent && Math.abs((Math.max(0,Math.min(target,maxTop)))-el.scrollTop)>8){ _lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight; diff --git a/tests/test_issue5637_mobile_scroll_clamp_jumpback.py b/tests/test_issue5637_mobile_scroll_clamp_jumpback.py index d69c63f9..c999d597 100644 --- a/tests/test_issue5637_mobile_scroll_clamp_jumpback.py +++ b/tests/test_issue5637_mobile_scroll_clamp_jumpback.py @@ -94,17 +94,19 @@ def test_content_visibility_scoped_to_user_rows(): ) -def test_restore_same_frame_holds_position_for_unpinned_reader(): - """The absolute-top fallback must be skipped for an unpinned reader. +def test_restore_same_frame_holds_position_for_unpinned_touch_reader(): + """The absolute-top fallback must be skipped when native anchoring can hold. When the reader is scrolled up (userUnpinned) and the anchor restore failed, _restoreMessageScrollSnapshotSameFrame must NOT write an absolute snapshot.top (it goes stale and nudges the viewport backward as scrollHeight grows, #5637). - It should hold position and return before the el.scrollTop write. + It should hold position and return before the el.scrollTop write. Desktop + intentionally falls through to the explicit fallback because it has no + native overflow-anchor layer. """ fn = _restore_same_frame_fn() - assert "snapshot.userUnpinned===true&&snapshot.pinned!==true" in fn, ( - "The fallback must guard the unpinned/anchor-failed case (#5637)." + assert "snapshot.userUnpinned===true&&snapshot.pinned!==true&&_fbTouchHold" in fn, ( + "The fallback must guard the unpinned/anchor-failed touch case (#5637)." ) # The guard must sit BEFORE the absolute-top scrollTop write and short-circuit it. guard_idx = fn.index("snapshot.userUnpinned===true&&snapshot.pinned!==true") diff --git a/tests/test_issue5637_stale_anchor_guard.py b/tests/test_issue5637_stale_anchor_guard.py index 3b83c094..e06e6c28 100644 --- a/tests/test_issue5637_stale_anchor_guard.py +++ b/tests/test_issue5637_stale_anchor_guard.py @@ -175,6 +175,7 @@ def test_realign_allows_on_desktop_no_native_anchor(): def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top, active_intent: bool = False, touch_like: bool = True, 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: """Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame. @@ -241,7 +242,7 @@ 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: false }}; + scrollHeight: {sh}, pinned: false, userUnpinned: {str(snapshot_user_unpinned).lower()} }}; eval(extractFunc('_desktopAnchorRealignDelta')); eval(extractFunc('_restoreMessageScrollSnapshotSameFrame')); _restoreMessageScrollSnapshotSameFrame(snapshot); @@ -312,6 +313,21 @@ def test_fallback_allows_snapshot_top_on_desktop_no_native_anchor(): assert m["messageUserUnpinned"] is False and m["scrollPinned"] is True +def test_unpinned_anchor_miss_restores_on_desktop_without_native_anchor(): + """A desktop reader who deliberately scrolled up cannot be left to native + anchoring: desktop disables it. If the semantic row is temporarily absent, + the rebuild may clamp scrollTop toward zero before this function runs, so the + fallback must restore the snapshot rather than taking the mobile no-write exit. + """ + m = json.loads(_run_node(_fallback_harness( + snapshot_scroll_height=90000, cur_scroll_height=90453, snapshot_top=89577, + active_intent=False, touch_like=False, snapshot_user_unpinned=True, + init_scroll_top=0, + ))) + assert m["writes"] == [89577] + 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 @@ -479,6 +495,7 @@ def test_predicate_stays_true_on_touch_when_inline_anchor_clobbered_to_none(): assert m["touchLike"] is True + def test_predicate_false_on_desktop_fine_pointer(): """Desktop (fine pointer): matchMedia('(pointer:coarse)') is false and the resting computed overflow-anchor is 'none' -> predicate reports NOT touch, so the guards do @@ -546,4 +563,3 @@ def test_predicate_true_on_android_not_ios(): platform="Linux armv8l", max_touch_points=5, ))) assert m["touchLike"] is True -