fix(desktop): anchor scrollback prepends in content space

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>
This commit is contained in:
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
2026-07-09 15:49:05 -04:00
co-authored by Tyler Longwell
parent 46613dccec
commit cfb06f502e
@@ -18,7 +18,27 @@ const TRUE_BOTTOM_THRESHOLD_PX = 1;
type AnchorState =
| { kind: "at-bottom" }
| { kind: "message"; messageId: string; topOffset: number };
/** `contentOffset` is the row's top relative to the scrolled CONTENT
* element (the container's first child), measured rect-to-rect. Content
* space is scroll-invariant: wheel motion translates the content and the
* row together, so the saved offset only drifts when layout ABOVE the row
* changes height. `viewportOffset` is retained only as a safety check for
* cases where the browser's native scroll anchoring has already absorbed a
* prepend; it must not be used as the correction target during a live fling.
* Two prior forms both jerked against a live fling: saving only the viewport
* offset (rect.top container top) made the prepend re-pin cancel any
* scrolling between anchor capture and the deferred commit; and deriving
* content space as viewport offset + `scrollTop` mixed two clocks — WebKit's
* committed scroll offset desyncs from getBoundingClientRect near the top
* clamp mid-momentum (the Lane C frozen-offset family), inflating drift by
* ~17px and over-correcting. Rect-to-rect against the content element reads
* a single clock. */
| {
kind: "message";
messageId: string;
contentOffset: number;
viewportOffset: number;
};
type BottomSettleContainer = Pick<
HTMLDivElement,
@@ -98,14 +118,25 @@ function isAtTrueBottom(
);
}
/**
* Top of the scrolled content in viewport coordinates. Measuring row
* positions against this (rect-to-rect) instead of `container top +
* scrollTop` reads a single layout clock — WebKit's committed `scrollTop`
* desyncs from getBoundingClientRect mid-momentum near the top clamp.
*/
function contentTopOf(container: HTMLDivElement): number {
return (container.firstElementChild ?? container).getBoundingClientRect().top;
}
/**
* Pick an anchor for the current scroll position.
*
* Top-crossing walk: chronological children, top-down. The first
* `data-message-id` row whose bottom edge has crossed below the container
* top is the anchor — that's the row the reader's eye is on when they've
* scrolled up through history. `topOffset` is the row's top relative to
* the container's top and may be negative when the row straddles the edge.
* scrolled up through history. `contentOffset` is the row's top relative to
* the scrolled content element — scroll-invariant, so saved state stays
* valid however far a fling travels before the next commit.
*
* If no such row exists (e.g. nothing scrolled past the top, list shorter
* than the viewport, etc.) the anchor is `at-bottom`.
@@ -121,6 +152,7 @@ function computeAnchor(container: HTMLDivElement): AnchorState {
}
const containerTop = container.getBoundingClientRect().top;
const contentTop = contentTopOf(container);
const rows = container.querySelectorAll<HTMLElement>("[data-message-id]");
for (let i = 0; i < rows.length; i++) {
@@ -132,7 +164,8 @@ function computeAnchor(container: HTMLDivElement): AnchorState {
return {
kind: "message",
messageId,
topOffset: rect.top - containerTop,
contentOffset: rect.top - contentTop,
viewportOffset: rect.top - containerTop,
};
}
}
@@ -251,6 +284,7 @@ export function useAnchoredScroll({
const rect = el.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const currentTopOffset = rect.top - containerRect.top;
const currentContentOffset = rect.top - contentTopOf(container);
const centeredTopOffset = (container.clientHeight - rect.height) / 2;
const maxScrollTop = Math.max(
0,
@@ -260,8 +294,6 @@ export function useAnchoredScroll({
maxScrollTop,
Math.max(0, container.scrollTop + currentTopOffset - centeredTopOffset),
);
const targetTopOffset =
currentTopOffset - (targetScrollTop - container.scrollTop);
container.scrollTo({
top: targetScrollTop,
@@ -269,13 +301,15 @@ export function useAnchoredScroll({
});
// Smooth scrolling starts an async animation, so measuring after the call can still return the pre-animation position.
// Save the clamped destination offset instead; otherwise a concurrent
// render/ResizeObserver restore can fight the smooth scroll back toward
// Save the current content-space offset; unlike a viewport offset, it is
// invariant under the pending scroll animation itself, so concurrent
// render/ResizeObserver restores do not fight the scroll back toward
// where it started.
anchorRef.current = {
kind: "message",
messageId,
topOffset: targetTopOffset,
contentOffset: currentContentOffset,
viewportOffset: currentTopOffset,
};
setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX);
@@ -414,8 +448,14 @@ export function useAnchoredScroll({
// Anchored mid-history. An older-history prepend grows the content above
// the reading row; the browser's native scroll anchoring does NOT correct
// this at the top edge (no anchor node above the viewport when scrollTop
// is ~0), so re-pin the anchored row to its saved offset by id. This is
// the single scroll writer for the prepend — the load-older observer only
// is ~0), so re-pin the anchored row by its saved CONTENT-space offset.
// Content-space drift measures only layout growth above the row — wheel
// motion between anchor capture and this deferred commit cancels out of
// the rect-to-rect read, so the compensation never rewinds a live fling
// (the once-per-page backward jerk). If native scroll anchoring has
// already held the row in viewport space, skip the JS write instead of
// double-applying the same prepend. This is the single scroll writer for
// the prepend — the load-older observer only
// triggers the fetch. We run it in this post-commit layout effect (not the
// observer's promise callback) because the prepended rows commit on a
// deferred snapshot a few frames later, so the row's true position is only
@@ -424,12 +464,31 @@ export function useAnchoredScroll({
`[data-message-id="${CSS.escape(anchor.messageId)}"]`,
);
if (row) {
const currentTopOffset =
row.getBoundingClientRect().top -
container.getBoundingClientRect().top;
const drift = currentTopOffset - anchor.topOffset;
if (Math.abs(drift) > 0.5) {
const rect = row.getBoundingClientRect();
const containerTop = container.getBoundingClientRect().top;
const currentContentOffset = rect.top - contentTopOf(container);
const currentViewportOffset = rect.top - containerTop;
const contentDrift = currentContentOffset - anchor.contentOffset;
const viewportDrift = currentViewportOffset - anchor.viewportOffset;
const nativeAnchorAlreadyHeld =
isPrepend && Math.abs(viewportDrift) <= 0.5;
const drift = nativeAnchorAlreadyHeld ? 0 : contentDrift;
if (nativeAnchorAlreadyHeld) {
anchorRef.current = {
...anchor,
contentOffset: currentContentOffset,
viewportOffset: currentViewportOffset,
};
} else if (Math.abs(drift) > 0.5) {
container.scrollBy(0, drift);
// The content under the viewport moved by `drift`; keep the saved
// anchor in step so a follow-up pass (second page, late reflow)
// measures fresh drift instead of re-applying this one.
anchorRef.current = {
...anchor,
contentOffset: currentContentOffset,
viewportOffset: currentViewportOffset - drift,
};
}
}
if (!isPrepend) {