fix(chat): suppress browser overflow-anchor during JS scroll-anchor realign (mobile scroll jump-back)

Root cause (mobile-only, never reproduces on desktop): .messages CSS resting
overflow-anchor is 'auto' on touch devices but 'none' on hover+fine-pointer
desktops (style.css media query). When _restoreMessageViewportAnchor writes
scrollTop to realign the reader's anchor row AND content height above the
viewport changed in the same frame, a mobile browser's native scroll-anchoring
ALSO shifts scrollTop -- the two compensations stack and yank the reader to an
unrelated earlier turn. Desktop never has the browser layer, which is why this
reproduced only on phones.

Fix: _suppressBrowserOverflowAnchor() sets overflow-anchor:none for the JS
scrollTop write, releases (restores prior value) next frame. Engages ONLY when
computed value is 'auto' (mobile) -- pure no-op on desktop (already none).

Verified on isolated debug instance (mobile-viewport Playwright):
- mobile auto: 800px above-viewport growth compensation 800px -> 0 (browser layer suppressed)
- desktop none: helper returns null, inline value untouched (byte-identical behavior)
- streaming: real turn, mid-read follow, 0 jumps, content held
- scroll-regression suite green
This commit is contained in:
allenliang2022 authored and nesquena-hermes committed 2026-07-01 17:46:03 +00:00
1 parent 9ced532de7
commit 0412aba45a
1 file changed
+58 -3
+58 -3
View File
@@ -824,6 +824,46 @@ function _captureMessageViewportAnchor(){
}
return null;
}
// Temporarily suppress the browser's native overflow-anchor on a scroll
// container so a JS scrollTop write is not double-compensated by the browser's
// own scroll-anchoring in the same frame. Returns a release fn that restores the
// prior inline value on the NEXT frame (after layout settles). No-op on desktop,
// where the resting computed value is already `none` (CSS hover/fine-pointer
// media query) — suppressing `none` changes nothing and the release restores the
// same empty inline value. Only mobile (resting `auto`) is actually affected,
// which is exactly where the double-compensation jump-back happens.
//
// Both this helper and _fixMobileScrollJank() gate on the SAME question — "is
// the browser's native scroll-anchor layer currently active on this element?" —
// routed through this one predicate so the two guards can't drift apart if the
// CSS media query ever changes (maintainer review on #5338). The computed-value
// test is more robust than a matchMedia('(hover:hover) and (pointer:fine)')
// check because it reflects the real resting value, including any inline
// override, not just the viewport media state.
function _browserOverflowAnchorActive(el){
if(!el) return false;
try{ return getComputedStyle(el).overflowAnchor==='auto'; }catch(_){ return false; }
}
function _suppressBrowserOverflowAnchor(container){
if(!container||!container.style) return null;
// Only engage when the browser layer is actually active (auto). On desktop
// (none) there is nothing to suppress.
if(!_browserOverflowAnchorActive(container)) return null;
const prevInline=container.style.overflowAnchor||'';
container.style.overflowAnchor='none';
let released=false;
return function _release(){
if(released) return;
released=true;
const restore=()=>{
// Only restore if we still own the suppression (another render may have
// re-set it); compare against the value we wrote.
if(container.style.overflowAnchor==='none') container.style.overflowAnchor=prevInline;
};
if(typeof requestAnimationFrame==='function') requestAnimationFrame(restore);
else restore();
};
}
function _restoreMessageViewportAnchor(anchor, rawIdxDelta){
const container=$('messages');
if(!container||!anchor) return false;
@@ -842,7 +882,19 @@ function _restoreMessageViewportAnchor(anchor, rawIdxDelta){
const rect=row.getBoundingClientRect();
const targetTop=Number(anchor.topOffset)||0;
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
// Mobile-only jump fix: the resting overflow-anchor on .messages is `auto` on
// touch devices (CSS media query keeps it `none` only for hover+fine-pointer
// desktops). When we write scrollTop here to realign the anchor row, a mobile
// browser's OWN overflow-anchor machinery ALSO shifts scrollTop in the same
// frame if content height above the viewport changed — the two compensations
// stack and yank the reader to an unrelated turn (the mobile jump-back). This is why the
// bug is mobile-only and never reproduces on a desktop (none) browser. Suppress
// the browser layer for this write; _releaseAnchorSuppression restores it next
// frame. Desktop is already `none`, so this is a no-op there.
const _releaseAnchorSuppression=(typeof _suppressBrowserOverflowAnchor==='function')
? _suppressBrowserOverflowAnchor(container) : null;
container.scrollTop+=(rect.top-containerRect.top)-targetTop;
if(_releaseAnchorSuppression) _releaseAnchorSuppression();
if(typeof _deferClearProgrammaticScroll==='function') _deferClearProgrammaticScroll();
else requestAnimationFrame(()=>{ setTimeout(()=>{ _programmaticScroll=false; },0); });
return true;
@@ -12184,9 +12236,12 @@ function _restoreMessageScrollSnapshot(snapshot){
window._fixMobileScrollJank=function _fixMobileScrollJank(){
const el=document.getElementById('messages');
if(!el) return;
// Desktop with a mouse: keep overflow-anchor:none (explicitly set by CSS).
// Mobile touch devices only: temporarily suppress anchor re-selection.
if(window.matchMedia('(hover:hover) and (pointer:fine)').matches) return;
// Route through the same "is the browser scroll-anchor layer active?" predicate
// as _suppressBrowserOverflowAnchor so the two guards can't drift (#5338 review).
// Desktop rests at overflow-anchor:none (predicate false) → nothing to suppress.
// Mobile touch devices rest at auto (predicate true) → temporarily suppress
// anchor re-selection during the wipe-and-rebuild gap.
if(!_browserOverflowAnchorActive(el)) return;
el.style.overflowAnchor='none';
requestAnimationFrame(()=>{
if(el.style.overflowAnchor==='none') el.style.overflowAnchor='';