fix(timeline): anchor virtualizer with scrollMargin for content above the list

Fixes two loading-state jank symptoms tho flagged on the main timeline, both
the same root cause.

Root cause: the scroll container holds content ABOVE the virtualized list inside
the SAME scrollable element — the pagination sentinel, the load-older spinner,
and the channel/DM intro banner. @tanstack/react-virtual positions items at
paddingStart + scrollMargin and defaults scrollMargin to 0, so it assumed row 0
sat at scrollTop 0 when it actually painted lower by the above-content height.
That offset mismatch produced:
  - the header/list 'sandwich' (freshly-loaded rows wedging into the seam between
    the intro/spinner and the list)
  - viewport drift while rows filled above + below (the anchor math was off by a
    variable above-content height)

Fix: measure the list's offset within the scroll container and feed it as the
virtualizer's scrollMargin (useVirtualScrollMargin — re-measures on a
ResizeObserver + when the intro/spinner/list visibility changes). Rows are now
positioned at virtualItem.start - scrollMargin within the spacer, which sits at
that offset, so item offsets line up with where they paint regardless of what's
above. The intro banner stays a sibling above the list and is only visible at
the very top, as intended; native key-stable prepend retention now holds steady
because the offset origin is correct.

Main timeline only; thread pane untouched. Adds a DOM test locking in the
start - scrollMargin positioning.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-15 21:16:46 -07:00
co-authored by Taylor Ho
parent 98005ccd57
commit a8b2159722
4 changed files with 132 additions and 1 deletions
@@ -23,6 +23,7 @@ import {
} from "./timelineEntryRender";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useVideoReviewContextById } from "./useVideoReviewContextById";
import { useVirtualScrollMargin } from "./useVirtualScrollMargin";
import { useVirtualTimelineScroll } from "./useVirtualTimelineScroll";
import { VirtualizedTimelineList } from "./VirtualizedTimelineList";
@@ -167,6 +168,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
const internalScrollRef = React.useRef<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const topSentinelRef = React.useRef<HTMLDivElement>(null);
// Wraps the virtualized list; its offset within the scroll container is the
// virtualizer's `scrollMargin` (content above it: sentinel, spinner, intro).
const listOuterRef = React.useRef<HTMLDivElement>(null);
// Gate the heavy timeline render (each row runs a synchronous
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
@@ -211,6 +215,23 @@ export const MessageTimeline = React.memo(function MessageTimeline({
? rows.length
: VIRTUAL_OVERSCAN;
// Offset of the virtualized list within the scroll container — content above
// it (sentinel, "load older" spinner, intro banner) lives in the SAME
// scrollable element, so the virtualizer must know that offset or rows paint
// at the wrong scrollTop (header/list sandwich + anchor drift on fill).
const scrollMargin = useVirtualScrollMargin(
scrollContainerRef,
listOuterRef,
[
isLoading,
isFetchingOlder,
deferredMessages.length,
channelIntro,
directMessageIntro,
rows.length,
],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollContainerRef.current,
@@ -224,6 +245,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
// before/after scrollHeight delta math, no double-rAF correction.
getItemKey: (index) => rows[index]?.key ?? index,
overscan,
// Account for the sentinel/spinner/intro above the list inside the same
// scroll container, so item offsets line up with where they actually paint.
scrollMargin,
});
const {
@@ -494,11 +518,13 @@ export const MessageTimeline = React.memo(function MessageTimeline({
isRenderPending && "opacity-60 transition-opacity",
)}
data-render-pending={isRenderPending ? "true" : undefined}
ref={listOuterRef}
>
<VirtualizedTimelineList
entries={entries}
renderEntry={renderEntry}
rows={rows}
scrollMargin={scrollMargin}
virtualizer={virtualizer}
/>
</div>
@@ -75,6 +75,7 @@ test("renders a day divider row with its formatted label", () => {
<VirtualizedTimelineList
entries={[]}
renderEntry={renderEntryStub}
scrollMargin={0}
rows={rows}
virtualizer={fakeVirtualizer(rows)}
/>,
@@ -95,6 +96,7 @@ test("dispatches message rows to their mapped entry, interleaved with dividers",
<VirtualizedTimelineList
entries={entries}
renderEntry={renderEntryStub}
scrollMargin={0}
rows={rows}
virtualizer={fakeVirtualizer(rows)}
/>,
@@ -112,6 +114,7 @@ test("renders nothing for an empty row list", () => {
<VirtualizedTimelineList
entries={[]}
renderEntry={renderEntryStub}
scrollMargin={0}
rows={[]}
virtualizer={fakeVirtualizer([])}
/>,
@@ -129,6 +132,7 @@ test("renders a message row's wrapper even if its entry is missing (no throw)",
<VirtualizedTimelineList
entries={[]}
renderEntry={renderEntryStub}
scrollMargin={0}
rows={rows}
virtualizer={fakeVirtualizer(rows)}
/>,
@@ -136,3 +140,32 @@ test("renders a message row's wrapper even if its entry is missing (no throw)",
assert.equal(container.querySelectorAll("[data-index]").length, 1);
assert.equal(screen.queryByTestId("entry"), null);
});
test("positions rows at start minus scrollMargin (content-above offset)", () => {
// With a scrollMargin of 128px, the first row (virtualItem.start = 0 from the
// fake virtualizer, since start includes the margin in real usage) must paint
// at translateY(start - 128). This is the fix for the header/list sandwich:
// the spacer sits at offsetTop = scrollMargin, so rows subtract it back out.
const rows: VirtualTimelineRow[] = [divider(DAY_1)];
const fake = {
getTotalSize: () => 64,
getVirtualItems: () => [
{ index: 0, key: rows[0].key, start: 200, size: 64, end: 264, lane: 0 },
],
measureElement: () => {},
} as unknown as Virtualizer<HTMLDivElement, Element>;
const { container } = render(
<VirtualizedTimelineList
entries={[]}
renderEntry={renderEntryStub}
scrollMargin={128}
rows={rows}
virtualizer={fake}
/>,
);
const wrapper = container.querySelector<HTMLElement>('[data-index="0"]');
assert.ok(wrapper);
// 200 - 128 = 72
assert.match(wrapper.style.transform, /translateY\(72px\)/);
});
@@ -11,6 +11,13 @@ type VirtualizedTimelineListProps = {
rows: VirtualTimelineRow[];
/** Filtered main-timeline entries, indexed by `VirtualMessageRow.messageIndex`. */
entries: MainTimelineEntry[];
/**
* The virtualizer's `scrollMargin` — the list's offset within the scroll
* container (content above it: sentinel, spinner, intro). `virtualItem.start`
* is in scroll-element coords (includes this margin), so rows are positioned
* at `start - scrollMargin` within the spacer, which sits at that offset.
*/
scrollMargin: number;
/**
* Renders one message entry's content. Injected (rather than imported) so the
* heavy `MessageRow` subtree stays out of this component's concern and the
@@ -34,6 +41,7 @@ export const VirtualizedTimelineList = React.memo(
virtualizer,
rows,
entries,
scrollMargin,
renderEntry,
}: VirtualizedTimelineListProps) {
const virtualItems = virtualizer.getVirtualItems();
@@ -63,7 +71,7 @@ export const VirtualizedTimelineList = React.memo(
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
}}
>
{row.kind === "day-divider" ? (
@@ -0,0 +1,64 @@
import * as React from "react";
/**
* Measure the virtualized list's offset from the top of the scroll container's
* scrollable content, to feed `useVirtualizer({ scrollMargin })`.
*
* The main timeline's scroll container holds content ABOVE the virtualized list
* inside the SAME scrollable element: the pagination sentinel, the
* "load older" spinner, and the channel/DM intro banner. `@tanstack/react-virtual`
* positions items at `paddingStart + scrollMargin`, so without this the
* virtualizer assumes row 0 sits at scrollTop 0 — but it's actually painted
* `scrollMargin` px lower. That mismatch is what makes freshly-loaded rows
* sandwich into the header/list seam and the viewport drift while rows fill.
*
* We re-measure whenever the above-content can change height (intro mount/
* unmount, spinner toggle) AND via a ResizeObserver on the scroll container, so
* the margin stays correct as content streams in.
*/
export function useVirtualScrollMargin(
scrollContainerRef: React.RefObject<HTMLDivElement | null>,
listOuterRef: React.RefObject<HTMLDivElement | null>,
// Re-measure triggers — values whose change can shift the list's offset.
deps: ReadonlyArray<unknown>,
): number {
const [scrollMargin, setScrollMargin] = React.useState(0);
React.useLayoutEffect(() => {
const container = scrollContainerRef.current;
const list = listOuterRef.current;
if (!container || !list) {
return;
}
const measure = () => {
const c = scrollContainerRef.current;
const l = listOuterRef.current;
if (!c || !l) {
return;
}
// Offset of the list within the scroll container's scrollable content:
// distance from the container's content top to the list's top.
const next = Math.round(
l.getBoundingClientRect().top -
c.getBoundingClientRect().top +
c.scrollTop,
);
setScrollMargin((current) => (current === next ? current : next));
};
measure();
if (typeof ResizeObserver === "undefined") {
return;
}
// The above-content lives inside the container; observe the container so a
// height change in the sentinel/spinner/intro re-measures the margin.
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
// deps drive intentional re-measures (intro/spinner/list visibility).
}, [scrollContainerRef, listOuterRef, ...deps]);
return scrollMargin;
}