When the anchor row is gone and a virtual top-spacer is present but the snapshot
carries no captured topPadBefore (null), Number(null) is 0, so the topPad-delta
branch would add the ENTIRE current spacer height to scrollTop and fling the reader
far from their content. Guard on an actual (non-null) captured topPadBefore; else
keep the raw fallback target. Mutation-checked (drop the null guard -> the reader is
flung 1300px and the test fails).
Round-2 gate-cert proved the round-1 'snapshot.top + delta' + nearest-to-current
arbiter is mathematically unwinnable: a row's rect offset is scroll-relative
(rect.top - containerRect.top = rowContentPos - scrollTop), so a staged absolute
target only equals the true content-hold when scrollTop == snapshot.top — and in
that exact case the arbiter measures |raw - cur| = 0 and picks raw, writing the
stale value (the reported up-jump, unfixed).
Rework to the app's own realign idiom (mirrors _restoreMessageViewportAnchor and
_compensateScrollForMeasurementDelta): when a measurable anchor row exists, write
el.scrollTop += (currentOffset - capturedOffset) from the LIVE scrollTop — a no-op
when already aligned, a heal when not, no arbiter. Restore the per-tier lookup
guard (key -> sessionIdx, never the rawIdx degradation that maps to a different
message after a virtualization re-window). Genuinely-gone anchor: mirror the
topPad-delta idiom the snapshot already carries (topPadBefore), else keep raw.
Gate-cert tests now use scroll-dependent rect mocks (rect.top = rowContentPos -
scrollTop) so certified hold numbers are physically realizable; mutation-checked
(revert to raw -> the hold tests fail; drop topPad-delta -> that test fails).
Round-2 gate cert (iOS CORE): the touch predicate _isTouchLikeMessageViewport
used matchMedia('(pointer:coarse)'), which is true on BOTH Android and iOS. But
overflow-anchor is inert on iOS WebKit (the repo's own static/style.css mobile
content-visibility block documents this — it deliberately does not set
overflow-anchor:none because it is a no-op on iOS and re-opens the #4856/#5338
jump on Android). So on iOS the stale-anchor refusal fired but its premise (let
the native overflow-anchor layer hold the viewport) is false → a scrolled-up iOS
reader was left unheld after above-viewport growth, the same class as the round-1
desktop regression, one platform over.
Split the platform check: add _isIOSWebKit() (classic iPhone/iPod/iPad UA, plus
iPadOS 13+ which masquerades as MacIntel but has maxTouchPoints>1 unlike a real
Mac) and exclude it from _isTouchLikeMessageViewport. The refusal now fires ONLY
on Android (pointer:coarse AND overflow-anchor actually works); desktop and iOS
both keep the explicit semantic realign / absolute snapshot.top restore.
Tests: 3 new predicate cases (iPhone, iPadOS-as-Mac, Android control), all
mutation-checked — removing the _isIOSWebKit exclusion fails exactly the iOS
cases while Android/desktop stay green; broadening _isIOSWebKit to any touch
device fails the Android control. The Node harness forces the navigator mock via
Object.defineProperty because Node 18+ ships a built-in read-only navigator that
a plain assignment silently ignores.
Note: iOS Safari cannot be exercised by the Node harness (the 'native anchor
holds' postcondition is mocked, never observed), so this needs a real iOS-Safari
recording before merge per the gate cert.
Address the two non-blocking observations from re-review of the touch-gate fix:
1. Add a direct unit test for _isTouchLikeMessageViewport itself — the guard-wiring
tests stub the predicate via a boolean, which validates that the _touchHold term
gates the refusal but not the predicate's own mid-realign stability (the claim that
motivated choosing matchMedia over the computed overflow-anchor probe). The new
_predicate_harness exercises the real predicate + _browserOverflowAnchorActive with
mocked matchMedia/getComputedStyle:
- test_predicate_stays_true_on_touch_when_inline_anchor_clobbered_to_none: on a
pointer:coarse device whose inline overflowAnchor was clobbered to 'none' by a
prior realign tick, the predicate must still report touch=true. Mutation-checked:
reverting the predicate to the bare computed probe makes exactly this test fail.
- test_predicate_false_on_desktop_fine_pointer / _falls_back_to_computed_probe_without_matchmedia
cover the desktop and no-matchMedia paths.
2. Note in the predicate comment that the no-matchMedia fallback to the computed probe
is best-effort (matchMedia('(pointer:coarse)') is universally supported in every
targeted browser, so the primary path is what runs).
The two streaming stale-anchor guards added for #5637 refuse a scroll
restore and rely on the browser's native overflow-anchor layer to hold
the viewport. That layer is only active where .messages computes to
overflow-anchor:auto (touch viewports). On hover+fine-pointer desktops
.messages is overflow-anchor:none, so refusing the restore leaves nothing
to hold the reader -> the desktop reader is yanked after above-viewport
growth (the same jump the guards fix on mobile), and the fallback also
latches _messageUserUnpinned=true.
Gate both refusals on _isTouchLikeMessageViewport(container), a
matchMedia('(pointer:coarse)') predicate (falling back to the computed
overflow-anchor probe) so they fire only where native anchoring can
actually hold the viewport. Desktop keeps its semantic scrollTop realign
and absolute snapshot.top fallback. matchMedia is used rather than the
computed-anchor probe alone because the realign temporarily writes inline
overflowAnchor:none for its own scroll write, which a computed probe would
misread mid-realign.
Add two desktop regression tests (touch_like=False) covering the exact
stale-anchor case on a no-native-anchor viewport; both are mutation-checked
(dropping the touch gate makes them fail).
Greptile review follow-ups (no behavior change):
- Remove the captured-but-never-read scrollTopAtCapture field from
_captureMessageViewportAnchor; only scrollHeightAtCapture is consulted.
- Rename _grewAbove -> _grewSinceCapture in the realign guard (the check is
overall scrollHeight growth since capture, not specifically above-viewport),
matching the fallback guard's _grewSinceSnap.
- Add a mutation-checked fallback active-intent test: content grew but the reader
has recent real input intent, so the absolute snapshot.top restore is kept.
A residual mobile scroll jump-back remained after #5638: while the reader is up in
history during a live stream, the anchor captured for a same-frame restore goes stale
as the streaming chunk grows content ABOVE the viewport. The anchor's captured
topOffset (and the absolute snapshot.top) no longer map to the same content, so
realigning to them yanks a still reader backward by a few hundred px per tick.
The existing snapshot.userUnpinned===true fallback skip does not cover it: the
scrollHeight-collapse scroll event re-pins the state machine (flips userUnpinned back to
false) mid-stream, so both the semantic realign (_restoreMessageViewportAnchor) and the
absolute snapshot.top fallback still fire.
Fix: two guards, both keyed on content-growth-since-capture + absence of recent real
input intent, NOT on a scrollTop diff — on an overflow-anchor:auto container the browser
itself writes scrollTop to compensate above-viewport growth, so a still reader's scrollTop
is not stationary; _recentMessage*ScrollIntent instead reflects genuine touch/wheel/key
input, which the browser's anchor layer never sets.
- _captureMessageViewportAnchor records scrollHeightAtCapture (+scrollTopAtCapture).
- _restoreMessageViewportAnchor refuses the realign when content grew since capture, there
is no recent intent, and the delta would move scrollTop >8px.
- the absolute snapshot.top fallback in _restoreMessageScrollSnapshotSameFrame mirrors the
same guard.
An actively scrolling reader (recent intent) keeps the legitimate restore; a fresh anchor
(no growth) and legacy snapshots without the captured geometry are unaffected. Adds
tests/test_issue5637_stale_anchor_guard.py (7 node-harness cases; two are mutation-checked
to fail if either guard is removed).