diff --git a/static/ui.js b/static/ui.js index fd765a90..6d54387f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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); diff --git a/tests/test_issue4856_android_scroll_regression.py b/tests/test_issue4856_android_scroll_regression.py index aa65e5b1..0c9ba7c6 100644 --- a/tests/test_issue4856_android_scroll_regression.py +++ b/tests/test_issue4856_android_scroll_regression.py @@ -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." + ) +