124 lines
5.6 KiB
Python
124 lines
5.6 KiB
Python
"""Regression tests for #5637: mobile scroll jump-back (two linked causes).
|
|
|
|
Real-device instrumentation (Android Chrome) traced the mobile scroll
|
|
jump-back to two causes:
|
|
|
|
1. Primary (CSS): the ``@media (pointer: coarse)`` block set a single flat
|
|
``content-visibility: auto; contain-intrinsic-size: auto 1px`` on every
|
|
``.msg-row``. Off-screen assistant rows (which can be multi-thousand px:
|
|
tool-result / long-answer) then reserved only ~1px each, so ``scrollHeight``
|
|
collapsed by tens of thousands of px on a long transcript, the browser
|
|
force-clamped ``scrollTop``, and the viewport jumped to the top. A flat
|
|
value cannot square "reserve tall rows" with "keep scrollHeight stable for
|
|
iOS flick momentum" (the reason 1px was chosen), so content-visibility is
|
|
now gated to the short, size-predictable user rows only; assistant rows keep
|
|
their real rendered height.
|
|
|
|
2. Secondary (JS): ``_restoreMessageScrollSnapshotSameFrame`` fell back to an
|
|
ABSOLUTE ``snapshot.top`` when the semantic anchor restore failed. During
|
|
streaming, the live activity-scene refresh fires this every tick; for a
|
|
reader scrolled up into history (unpinned), snapping to a stale absolute top
|
|
nudges the viewport backward by an amount that grows with ``scrollHeight``.
|
|
The fix holds position (no scrollTop write) for the unpinned/anchor-failed
|
|
case and lets the browser's own scroll anchoring keep the reader put.
|
|
|
|
Both tests fail on the pre-fix tree and pass only on the fixed tree.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
|
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
|
|
|
|
|
def _coarse_pointer_block() -> str:
|
|
"""Return the @media (pointer: coarse) block body from style.css."""
|
|
anchor = "@media (pointer: coarse)"
|
|
start = STYLE_CSS.index(anchor)
|
|
brace = STYLE_CSS.index("{", start)
|
|
depth = 0
|
|
for i in range(brace, len(STYLE_CSS)):
|
|
ch = STYLE_CSS[i]
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return STYLE_CSS[brace : i + 1]
|
|
raise AssertionError("@media (pointer: coarse) block not found")
|
|
|
|
|
|
def _restore_same_frame_fn() -> str:
|
|
"""Return the _restoreMessageScrollSnapshotSameFrame function body."""
|
|
anchor = "function _restoreMessageScrollSnapshotSameFrame"
|
|
start = UI_JS.index(anchor)
|
|
brace = UI_JS.index("{", start)
|
|
depth = 0
|
|
for i in range(brace, len(UI_JS)):
|
|
ch = UI_JS[i]
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return UI_JS[start : i + 1]
|
|
raise AssertionError("_restoreMessageScrollSnapshotSameFrame not found")
|
|
|
|
|
|
def test_content_visibility_not_flat_on_all_msg_rows():
|
|
"""content-visibility:auto must NOT be applied to a bare .msg-row selector.
|
|
|
|
A flat `.msg-row { content-visibility: auto; contain-intrinsic-size: auto 1px }`
|
|
collapses tall assistant rows' off-screen height and drives the scrollHeight
|
|
clamp jump-back (#5637). The rule must be scoped so tall assistant rows are
|
|
not off-screen-skipped with a tiny flat estimate.
|
|
"""
|
|
block = _coarse_pointer_block()
|
|
# The exact pre-fix line: content-visibility on an unqualified .msg-row.
|
|
assert ".msg-row { content-visibility: auto; contain-intrinsic-size: auto 1px" not in block, (
|
|
"Flat content-visibility on every .msg-row is the #5637 primary cause; "
|
|
"it must be scoped away from tall (assistant) rows."
|
|
)
|
|
|
|
|
|
def test_content_visibility_scoped_to_user_rows():
|
|
"""content-visibility should be retained for the short user rows only."""
|
|
block = _coarse_pointer_block()
|
|
assert 'content-visibility: auto' in block, (
|
|
"The iOS off-screen-skip optimization should still apply to short rows."
|
|
)
|
|
assert '.msg-row[data-role="user"]' in block, (
|
|
"content-visibility:auto must be scoped to user rows (size-predictable), "
|
|
"not applied flat to assistant rows that can be multi-thousand px (#5637)."
|
|
)
|
|
|
|
|
|
def test_restore_same_frame_holds_position_for_unpinned_touch_reader():
|
|
"""The absolute-top fallback must be skipped when native anchoring can hold.
|
|
|
|
When the reader is scrolled up (userUnpinned) and the anchor restore failed,
|
|
_restoreMessageScrollSnapshotSameFrame must NOT write an absolute snapshot.top
|
|
(it goes stale and nudges the viewport backward as scrollHeight grows, #5637).
|
|
It should hold position and return before the el.scrollTop write. Desktop
|
|
intentionally falls through to the explicit fallback because it has no
|
|
native overflow-anchor layer.
|
|
"""
|
|
fn = _restore_same_frame_fn()
|
|
assert "snapshot.userUnpinned===true&&snapshot.pinned!==true&&_fbTouchHold" in fn, (
|
|
"The fallback must guard the unpinned/anchor-failed touch case (#5637)."
|
|
)
|
|
# The guard must sit BEFORE the absolute-top scrollTop write and short-circuit it.
|
|
guard_idx = fn.index("snapshot.userUnpinned===true&&snapshot.pinned!==true")
|
|
# the desktop fallback scrollTop write (round-3: realigned _fbTarget, not raw snapshot.top)
|
|
write_idx = fn.index("el.scrollTop=_fbTarget")
|
|
assert guard_idx < write_idx, (
|
|
"The unpinned guard must precede (and return before) the fallback "
|
|
"scrollTop write so it is never reached for an unpinned reader (#5637)."
|
|
)
|
|
# Between the guard and the write there must be an early return.
|
|
between = fn[guard_idx:write_idx]
|
|
assert "return;" in between, (
|
|
"The unpinned guard must `return` before the fallback write (#5637)."
|
|
)
|