mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
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:
+33
-3
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user