mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
98005ccd57
commit
a8b2159722
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user