Skip to content

fix(desktop): anchor scrollback prepends in content space#1687

Draft
tlongwell-block wants to merge 1 commit into
mainfrom
eva/content-space-anchor
Draft

fix(desktop): anchor scrollback prepends in content space#1687
tlongwell-block wants to merge 1 commit into
mainfrom
eva/content-space-anchor

Conversation

@tlongwell-block

@tlongwell-block tlongwell-block commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Store message scroll anchors in content space (row top relative to the scrolled content element, measured rect-to-rect) instead of viewport space.
  • Keep a viewport-space reading only as a native-scroll-anchoring safety check so the JS re-pin cannot double-apply a prepend the browser already absorbed.
  • Update the smooth-scroll anchor path to preserve the content-space anchor instead of a stale viewport target.

Single file: desktop/src/features/messages/ui/useAnchoredScroll.ts (+75/−16). No flag — this is a straight bug fix to the existing writer, not a behavior experiment.

Why

During scrollback pagination, the older-history page commits on the deferred snapshot a few frames after the last scroll event. The anchor saved rect.top − containerTop in viewport space at that last scroll event; momentum kept scrolling during the gap; the prepend re-pin then scrollBy()-ed back to the stale offset — snapping the reading row down by exactly the distance scrolled in between, once per ~50-message page boundary. That is the reported "intermittent stutter down when scrolling up."

Content space is scroll-invariant by construction: wheel motion translates content and row together, so measured drift is only layout growth above the anchor — the compensation can no longer rewind a live fling. Rect-to-rect against the content element (not viewport + scrollTop) deliberately reads a single layout clock; WebKit's committed scrollTop desyncs from getBoundingClientRect mid-momentum (the Lane C frozen-offset family), and mixing the two clocks re-introduced ~17px of false drift.

Evidence (mid-fling-prepend probe)

New instrument: fling through real windowed pagination (600-row history served through getChannelWindowEvents, so fetchOlder genuinely fires mid-momentum), per-frame anchor sampling. Every prior instrument — including the portable classifier — used fully pre-loaded corpora where fetchOlder never fires, which is why they scored clean on builds that still stuttered.

build chromium webkit
main (= #1678 flag-off) jerk 17–20px at every prepend jerk 17–20px at every prepend
this fix 12 prepends, 1 jerk (18px) 12 prepends, 3 jerks (max 20px)
this fix + #1678 eager layout 12 prepends, 1 jerk 11 prepends, 0 jerks

The residual jerks are a different write class: content-visibility re-realization above the anchor at commit time — exactly what #1678 (eagerTimelineLayout) removes. The two changes compose: this fix kills the stale-offset rewind (the per-page-boundary stutter), #1678 kills realization drift. All numbers repeatable across runs; probe run against this exact tree (f46ebe2c).

Validation

  • pnpm -C desktop test — 2266 passed
  • pnpm -C desktop typecheck / lint — clean (one pre-existing useOptionalChain warning in useAutoRestartPolicy.ts, untouched)
  • pnpm -C desktop build — passed
  • Playwright smoke on this tree: timeline-no-shift + scroll-history + overscroll-boundary + channel-window-mock-paging24/24
  • messaging.spec.ts — 29/31; the 2 fails (:164 link previews, :533 avatar attribute) reproduce identically on the base commit 46613dcc with this change absent — pre-existing, not introduced here
  • Mid-fling probe both engines, fix-only and composed with feat(desktop): eagerTimelineLayout preview — lay out fetched rows immediately (no content-visibility skipping) #1678 (table above)

Notes

The reading anchor saved its offset in viewport space at the last scroll
event, but the older-history page commits on the deferred snapshot a few
frames later. Momentum scrolling in between made the prepend re-pin
scrollBy() back to a stale offset — snapping the reading row down by the
distance scrolled during the gap, once per ~50-message page boundary
("intermittent stutter down while scrolling up").

Save the anchor in content space instead (row top relative to the scrolled
content element, rect-to-rect): scroll-invariant by construction, so the
measured drift is exactly the layout growth above the row and the
compensation never rewinds a live fling. Keep a viewport-space reading only
to detect when native scroll anchoring already held the row, so the JS
re-pin cannot double-apply the same prepend.

Verified with a mid-fling-prepend probe (fling through real windowed
pagination, per-frame anchor sampling): before, every mid-fling prepend
jerked the anchor row down 17–20px on both engines; after, chromium 0
jerks, webkit residuals only from content-visibility realization (0 when
composed with eagerTimelineLayout, #1678).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
@tlongwell-block tlongwell-block force-pushed the eva/content-space-anchor branch from bcfbbcc to cfb06f5 Compare July 9, 2026 19:49
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.

1 participant