Skip to content

Release — suppress mobile scroll jump-back on message realign (#5338) - #5352

Merged
nesquena-hermes merged 5 commits into
masterfrom
stage-5338
Jul 1, 2026
Merged

nesquena-hermes merged 5 commits into
masterfrom
stage-5338

Conversation

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Release — suppress mobile scroll jump-back on message realign (#5338)

PR #5338 by @allenliang2022. On mobile, realigning the viewport after loading older messages caused a visible scroll jump-back: the app's scrollTop write and the browser's own overflow-anchor machinery both compensated in the same frame.

Fix (static/ui.js)

  • _suppressBrowserOverflowAnchor(container) sets overflow-anchor:none only when the browser layer is actually active (getComputedStyle().overflowAnchor === 'auto', i.e. mobile), releasing on the next requestAnimationFrame and restoring the prior inline value only if still owned.
  • Desktop (resting none) is a no-op — helper returns null, nothing changes.
  • Both this and _fixMobileScrollJank() route through one shared predicate _browserOverflowAnchorActive so the guards can't drift.

Gate

Credit: @allenliang2022.

allenliang2022 and others added 5 commits July 1, 2026 17:46
…ealign (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
…er 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.
…thAnchorSuppression refactor (#5338)

Commit 7536f6f routed the deferred post-render dispatches through
_postProcessWithAnchorSuppression() (holds overflow-anchor suppression across
the async media/layout settle frame, then calls postProcessRenderedMessages).
Six pre-existing tests string-matched the old
'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' literal and
failed on the rename — behavior is preserved (the wrapper still invokes
postProcessRenderedMessages), so this is a test-fix not a code-fix.

Per the gate-cert recommendation, the tests now assert the BEHAVIOR chain
(post-render is scheduled via _postProcessWithAnchorSuppression, and that
wrapper calls postProcessRenderedMessages) rather than the exact rAF literal, so
a future wrapper rename can't re-orphan them.

Files: test_csv_table_rendering, test_excalidraw_inline_embed,
test_issue483_inline_diff_viewer, test_issue484_json_tree_viewer, test_issue347,
test_pdf_html_preview. Verified: the 6 updated assertions pass locally (the only
local failures are the pre-existing Windows-only WinError 206 command-line-too-long
in Node-harness tests, unrelated, green on Linux CI).
…ges node harness (#5338)

The Node-executed gate in test_anchor_fallback_ownership.py
(test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds)
eval()s the real renderMessages(). Commit 7536f6f made renderMessages schedule
its post-render pass via _postProcessWithAnchorSuppression(), but the harness
only stubbed postProcessRenderedMessages() — so the eval threw
'ReferenceError: _postProcessWithAnchorSuppression is not defined' and the test
failed on Linux CI (shard 2). It passed locally only because Windows hit the
unrelated WinError 206 command-line-too-long first, masking the real error.

Add a no-op stub for _postProcessWithAnchorSuppression alongside the existing
postProcessRenderedMessages stub. Verified by dumping the generated node script
to a temp .js file and running 'node file.js' (bypassing the Windows -e length
limit): the eval no longer throws and the test's assertions pass.
@nesquena-hermes
nesquena-hermes merged commit 412cf01 into master Jul 1, 2026
17 of 18 checks passed
@nesquena-hermes
nesquena-hermes deleted the stage-5338 branch July 1, 2026 17:48
@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This release PR ports #5338 into master, fixing a mobile-only scroll jump-back caused by the browser's overflow-anchor machinery and the app's own scrollTop write both compensating for above-viewport height changes in the same frame. It also closes the second vector where the deferred postProcessRenderedMessages rAF (which runs syntax highlighting, katex/mermaid, and media embeds) was unguarded and could trigger a second browser re-anchor after the synchronous fix had already released.

  • Adds _browserOverflowAnchorActive(el) as a shared predicate (computed-style, not matchMedia) and _suppressBrowserOverflowAnchor(container) that holds overflow-anchor:none only when the browser layer is genuinely active, then restores the prior inline value via a deferred rAF with ownership tracking to prevent double-restore races.
  • Wraps all three requestAnimationFrame(()=>postProcessRenderedMessages(...)) dispatch sites with _postProcessWithAnchorSuppression, which holds suppression across the post-process frame and one extra rAF to cover async image-decode/katex/mermaid layout reflow; desktop (resting computed none) is a verified no-op throughout.
  • Updates nine test files to assert on the new wrapper call-site, and adds a dedicated regression test test_post_process_runs_under_overflow_anchor_suppression covering the async settle window.

Confidence Score: 4/5

Safe to merge — desktop is a verified no-op, the ownership-guarded restore correctly handles concurrent suppression calls, and all three post-render dispatch sites are now wrapped.

The core fix is well-scoped and the two-frame suppression window in _postProcessWithAnchorSuppression is intentional (one extra rAF from the wrapper, then _suppressBrowserOverflowAnchor's own rAF for the actual restore). The only finding is a cosmetic comment wrap in _restoreMessageViewportAnchor.

static/ui.js — the new suppression helpers and the _postProcessWithAnchorSuppression wrapper; otherwise all test files look correct.

Important Files Changed

Filename Overview
static/ui.js Adds _browserOverflowAnchorActive, _suppressBrowserOverflowAnchor, and _postProcessWithAnchorSuppression; updates _fixMobileScrollJank guard and wraps all three post-render rAF dispatch sites. Logic is correct; one cosmetic long-line comment wrap in _restoreMessageViewportAnchor.
tests/test_issue4856_android_scroll_regression.py Adds test_post_process_runs_under_overflow_anchor_suppression covering the async settle window; assertions are well-scoped and verify all three dispatch sites via count >= 3.
tests/test_csv_table_rendering.py Updated brittle literal match to assert on the new _postProcessWithAnchorSuppression wrapper and verifies postProcessRenderedMessages is still called inside it.
tests/test_pdf_html_preview.py Updates count assertion from old literal (==2) to new wrapper form; adds chain check for the wrapper body.
tests/test_anchor_fallback_ownership.py Adds _postProcessWithAnchorSuppression stub to the harness so the renderMessages call under test doesn't throw on the new dispatch site.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant R as renderMessages()
    participant RVA as _restoreMessageViewportAnchor()
    participant SBA as _suppressBrowserOverflowAnchor()
    participant PPW as _postProcessWithAnchorSuppression()
    participant PPR as postProcessRenderedMessages()
    participant Browser as Browser Layout

    Note over R,Browser: Frame N — render + scroll-restore
    R->>RVA: call (preserveScroll)
    RVA->>SBA: _suppressBrowserOverflowAnchor(container)
    SBA-->>RVA: release_A fn (inline → 'none')
    RVA->>Browser: "container.scrollTop += delta"
    RVA->>SBA: release_A() [sync]
    SBA->>Browser: requestAnimationFrame(restore_A)
    R->>Browser: requestAnimationFrame(_postProcessWithAnchorSuppression)

    Note over R,Browser: Frame N+1 — post-process + suppress
    Browser->>SBA: "restore_A fires → inline = ''"
    Browser->>PPW: _postProcessWithAnchorSuppression(inner)
    PPW->>SBA: _suppressBrowserOverflowAnchor(scroller)
    SBA-->>PPW: release_B fn (inline → 'none')
    PPW->>PPR: postProcessRenderedMessages(container)
    PPR-->>PPW: (highlight, katex, mermaid, media)
    PPW->>Browser: requestAnimationFrame(release_B) [finally]

    Note over R,Browser: Frame N+2 — deferred release
    Browser->>SBA: release_B fires → schedules restore_B rAF

    Note over R,Browser: Frame N+3 — restore
    Browser->>SBA: "restore_B fires → inline = '' (restored)"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant R as renderMessages()
    participant RVA as _restoreMessageViewportAnchor()
    participant SBA as _suppressBrowserOverflowAnchor()
    participant PPW as _postProcessWithAnchorSuppression()
    participant PPR as postProcessRenderedMessages()
    participant Browser as Browser Layout

    Note over R,Browser: Frame N — render + scroll-restore
    R->>RVA: call (preserveScroll)
    RVA->>SBA: _suppressBrowserOverflowAnchor(container)
    SBA-->>RVA: release_A fn (inline → 'none')
    RVA->>Browser: "container.scrollTop += delta"
    RVA->>SBA: release_A() [sync]
    SBA->>Browser: requestAnimationFrame(restore_A)
    R->>Browser: requestAnimationFrame(_postProcessWithAnchorSuppression)

    Note over R,Browser: Frame N+1 — post-process + suppress
    Browser->>SBA: "restore_A fires → inline = ''"
    Browser->>PPW: _postProcessWithAnchorSuppression(inner)
    PPW->>SBA: _suppressBrowserOverflowAnchor(scroller)
    SBA-->>PPW: release_B fn (inline → 'none')
    PPW->>PPR: postProcessRenderedMessages(container)
    PPR-->>PPW: (highlight, katex, mermaid, media)
    PPW->>Browser: requestAnimationFrame(release_B) [finally]

    Note over R,Browser: Frame N+2 — deferred release
    Browser->>SBA: release_B fires → schedules restore_B rAF

    Note over R,Browser: Frame N+3 — restore
    Browser->>SBA: "restore_B fires → inline = '' (restored)"
Loading

Reviews (1): Last reviewed commit: "docs(changelog): suppress mobile overflo..." | Re-trigger Greptile

Comment thread static/ui.js
Comment on lines +890 to +891
// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The inline comment on this line has an accidental mid-sentence line-break: "…the mobile jump-back). This is why the" dangles at the end of line 889, turning the sentence split across two comment lines. Consider pulling the trailing fragment onto the next line so the sentence reads cleanly.

Suggested change
// 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
// 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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants