fix(chat): keep overflow-anchor suppressed across the async post-render settle window (mobile jump-back)

The sync-frame guards (_fixMobileScrollJank / _suppressBrowserOverflowAnchor)
only cover the render frame itself. postProcessRenderedMessages() — syntax
highlight, inline diff/csv/pdf/html/excalidraw, katex/mermaid — is scheduled a
FRAME LATER via requestAnimationFrame(), after those guards have released. Each
of those can change the height of rows ABOVE the viewport; on mobile
(overflow-anchor:auto) the browser's native anchor engine then compensates
scrollTop a SECOND time in that unguarded frame, yanking an unpinned reader to
another turn (the residual mobile 往回大跳).

Wrap all three deferred post-process dispatches (fast-path cache branch, main
render tail, live-tool remount) in _postProcessWithAnchorSuppression(), which
routes through the shared _suppressBrowserOverflowAnchor() and holds suppression
one extra frame so late media/layout reflow is covered too. Desktop rests at
overflow-anchor:none so the wrapper is a verified no-op there.

Reproduced on an isolated debug instance with a cloned 1179-message session:
above-viewport +350px during the async settle window jumped scrollTop +350 on
mobile (auto) and 0 with the wrapper; desktop (none) 0 both ways. static/ui.js
only.
This commit is contained in:
allenliang2022
2026-07-01 23:23:08 +08:00
parent 9b61b97b56
commit 7536f6f16d
2 changed files with 89 additions and 3 deletions
+33 -3
View File
@@ -7907,7 +7907,7 @@ function restoreLiveTurnHtmlForSession(sid){
const liveGroup=restored.querySelector('.tool-call-group[data-live-tool-call-group="1"]');
if(liveGroup&&typeof _startActivityElapsedTimer==='function') _startActivityElapsedTimer(liveGroup);
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
requestAnimationFrame(()=>postProcessRenderedMessages(restored));
requestAnimationFrame(()=>_postProcessWithAnchorSuppression(restored));
return true;
}
@@ -12530,7 +12530,7 @@ function renderMessages(options){
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
if(_maybeRecoverVirtualizedBlankViewport(options, preserveScroll, virtualWindow)) return;
_updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow);
requestAnimationFrame(()=>postProcessRenderedMessages(inner));
requestAnimationFrame(()=>_postProcessWithAnchorSuppression(inner));
if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver();
if(typeof loadTodos==='function'&&document.getElementById('panelTodos')&&document.getElementById('panelTodos').classList.contains('active')){loadTodos();}
return;
@@ -13884,7 +13884,7 @@ function renderMessages(options){
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
if(_maybeRecoverVirtualizedBlankViewport(options, preserveScroll, virtualWindow)) return;
// Apply syntax highlighting after DOM is built
requestAnimationFrame(()=>postProcessRenderedMessages(inner));
requestAnimationFrame(()=>_postProcessWithAnchorSuppression(inner));
// Refresh todo panel if it's currently open
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
loadTodos();
@@ -15010,6 +15010,36 @@ async function regenerateResponse(btn) {
} catch(e) { setStatus(t('regen_failed') + e.message); }
}
// postProcessRenderedMessages() runs one frame AFTER the render + JS scroll
// restore (it is scheduled via requestAnimationFrame). It performs syntax
// highlighting, inline diff/csv/pdf/html/excalidraw hydration, mermaid/katex
// rendering — all of which can CHANGE the height of rows above the viewport.
//
// On mobile the scroller rests at overflow-anchor:auto, so any above-viewport
// height change in this post-render frame makes the browser's native anchor
// engine compensate scrollTop a SECOND time — after the JS restore already
// settled the reader's position — yanking them to an unrelated turn ("往回大跳").
// The synchronous _fixMobileScrollJank / _suppressBrowserOverflowAnchor guards
// only cover the render frame itself; they have already released by the time
// this rAF fires. Wrap the post-process (and the media-reflow frame right after
// it) in the same suppression so the browser layer cannot re-anchor during the
// async settle window. Desktop rests at `none`, so this is a no-op there.
function _postProcessWithAnchorSuppression(container){
const scroller=$('messages');
const release=(scroller&&typeof _suppressBrowserOverflowAnchor==='function')
? _suppressBrowserOverflowAnchor(scroller) : null;
try{
postProcessRenderedMessages(container);
}finally{
// Hold suppression across ONE more frame so late media/layout reflow
// (image decode, katex/mermaid measure) cannot re-anchor either, then let
// _suppressBrowserOverflowAnchor's own rAF-deferred restore run.
if(release){
if(typeof requestAnimationFrame==='function') requestAnimationFrame(release);
else release();
}
}
}
function postProcessRenderedMessages(container) {
highlightCode(container);
addCopyButtons(container);
@@ -514,3 +514,59 @@ def test_streaming_tick_calls_fix_before_dom_writes():
assert guard_idx < render_idx, (
"The mobile scroll-jank guard must run before streaming DOM work begins."
)
def test_post_process_runs_under_overflow_anchor_suppression():
"""#5338 follow-up: the async post-render settle window must stay suppressed.
Root cause of the residual mobile "往回大跳": postProcessRenderedMessages()
is scheduled a FRAME LATER via requestAnimationFrame(), after the synchronous
_fixMobileScrollJank()/_suppressBrowserOverflowAnchor() guards have already
released. It runs highlightCode()/load*Inline()/katex/mermaid, all of which
can change the height of rows ABOVE the viewport. On mobile (overflow-anchor:
auto) the browser's native anchor engine then compensates scrollTop a SECOND
time in that unguarded frame, yanking an unpinned reader to another turn.
The fix wraps every deferred post-process in _postProcessWithAnchorSuppression()
so the browser layer stays suppressed across the post-process + one media-reflow
frame. Desktop rests at overflow-anchor:none so the wrapper is a no-op there.
"""
# The wrapper exists and engages the shared suppression helper.
wrapper_idx = UI_JS.find("function _postProcessWithAnchorSuppression(")
assert wrapper_idx != -1, (
"_postProcessWithAnchorSuppression() wrapper must exist to keep the "
"browser overflow-anchor layer suppressed across the deferred post-render "
"settle window (#5338 mobile 往回大跳 follow-up)."
)
wrapper = UI_JS[wrapper_idx: wrapper_idx + 900]
assert "_suppressBrowserOverflowAnchor(scroller)" in wrapper, (
"_postProcessWithAnchorSuppression() must route through the shared "
"_suppressBrowserOverflowAnchor() helper so desktop stays a verified no-op."
)
assert "postProcessRenderedMessages(container)" in wrapper, (
"_postProcessWithAnchorSuppression() must still call the real "
"postProcessRenderedMessages() inside the suppression window."
)
# Suppression is held across ONE extra frame so late media/layout reflow
# cannot re-anchor either.
assert "requestAnimationFrame(release)" in wrapper, (
"_postProcessWithAnchorSuppression() must defer the suppression release "
"by one frame so image-decode / katex / mermaid reflow is also covered."
)
# EVERY deferred post-process dispatch must go through the wrapper — a raw
# requestAnimationFrame(()=>postProcessRenderedMessages(...)) would leave that
# path unguarded and re-open the jump.
raw_dispatch = "requestAnimationFrame(()=>postProcessRenderedMessages("
assert raw_dispatch not in UI_JS, (
"All deferred postProcessRenderedMessages() dispatches must go through "
"_postProcessWithAnchorSuppression(); a raw rAF dispatch re-opens the "
"unguarded async settle window on mobile (#5338)."
)
wrapped_dispatch = "requestAnimationFrame(()=>_postProcessWithAnchorSuppression("
assert UI_JS.count(wrapped_dispatch) >= 3, (
"All three post-render paths (fast-path cache branch, main render tail, "
"live-tool remount) must dispatch post-process through the suppression "
"wrapper; found fewer than 3."
)