Preserve smooth scrolling for rendered search targets

Keep nearby, already-rendered find-in-channel transitions smooth while forcing distant virtualized realization to remain instant. Map the behavior explicitly onto Virtua and cover both sides of the hybrid contract.

Co-authored-by: Rivet <a08d9a8418c7ff03afe19964724c8fd87bf1776ab9e9b9cafb8cc920edd02a6e@buzz.block.builderlab.xyz>
Signed-off-by: Rivet <a08d9a8418c7ff03afe19964724c8fd87bf1776ab9e9b9cafb8cc920edd02a6e@buzz.block.builderlab.xyz>
This commit is contained in:
Rivet
2026-08-24 13:01:49 -07:00
parent 4c42aff110
commit bd721e9eb2
6 changed files with 108 additions and 11 deletions

View File

@@ -33,6 +33,7 @@ import { UnreadDivider } from "./UnreadDivider";
import { useTimelineRetention } from "./useTimelineRetention";
import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel";
import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle";
import { getVirtualMessageScrollOptions } from "./virtualMessageScroll";
export type TimelineVirtualizerApi = {
cancelBottomIntent: () => void;
@@ -670,11 +671,14 @@ function VirtualizedTimelineRows({
settleAtBottom();
},
settleAtBottom,
scrollToMessage(messageId) {
scrollToMessage(messageId, options) {
cancelBottomSettle();
const index = messageItemIndexByIdRef.current.get(messageId);
if (index === undefined) return false;
listRef.current?.scrollToIndex(index, { align: "center" });
listRef.current?.scrollToIndex(
index,
getVirtualMessageScrollOptions(options?.behavior),
);
return true;
},
};

View File

@@ -266,6 +266,30 @@ function BottomStateHarness({
return null;
}
function VirtualScrollBehaviorHarness({ refs }) {
const didRun = React.useRef(false);
const anchored = useAnchoredScroll({
channelId: "conversation",
contentRef: refs.content,
isLoading: false,
messages: [{ id: "selected" }],
scrollContainerRef: refs.scroller,
virtualizerOwnsPrependAnchoring: true,
virtualScrollToMessage: (messageId, options) => {
refs.targetJumps.push({ messageId, options });
return true;
},
});
React.useLayoutEffect(() => {
if (didRun.current) return;
didRun.current = true;
refs.targetResult = anchored.scrollToMessage("selected", {
behavior: "smooth",
});
}, [anchored.scrollToMessage, refs]);
return null;
}
function VirtualTargetHarness({ refs }) {
const didRun = React.useRef(false);
const bottomApi = useVirtualizedBottomSettle(
@@ -525,6 +549,47 @@ test("user interaction releases and retires a pending pinned target", async () =
await act(async () => root.unmount());
});
test("virtual search scrolling stays smooth only for an already-rendered target", async () => {
for (const rendered of [true, false]) {
const content = document.createElement("div");
const scroller = document.createElement("div");
scroller.clientHeight = 400;
scroller.scrollHeight = 1_000;
scroller.scrollTop = 0;
scroller.getBoundingClientRect = () => ({ bottom: 400, top: 0 });
const row = {
getBoundingClientRect: () => ({
bottom: 290,
height: 40,
top: 250,
}),
};
scroller.querySelector = () => (rendered ? row : null);
scroller.querySelectorAll = () => [];
scroller.appendChild(content);
const refs = {
content: { current: content },
scroller: { current: scroller },
targetJumps: [],
targetResult: null,
};
const root = createRoot(document.createElement("div"));
await act(async () => {
root.render(React.createElement(VirtualScrollBehaviorHarness, { refs }));
});
assert.deepEqual(refs.targetJumps, [
{
messageId: "selected",
options: { behavior: rendered ? "smooth" : "auto" },
},
]);
assert.equal(refs.targetResult, rendered ? "centered" : "pending");
await act(async () => root.unmount());
}
});
test("mounted virtual target retires bottom intent and delegates the jump to the virtualizer", async () => {
const resizeObservers = [];
globalThis.ResizeObserver = class {

View File

@@ -480,9 +480,12 @@ export function useAnchoredScroll({
// takes the viewport away from that settle; disarming the durable
// bottom intent stops the ResizeObserver from re-pinning later.
virtualCancelBottomIntent?.();
const virtualScrollBehavior = el
? (options.behavior ?? "auto")
: "auto";
if (
!virtualScrollToMessage(messageId, {
behavior: options.behavior ?? "auto",
behavior: virtualScrollBehavior,
})
) {
return "missing";

View File

@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getVirtualMessageScrollOptions } from "./virtualMessageScroll.ts";
test("maps smooth message navigation to Virtua's smooth option", () => {
assert.deepEqual(getVirtualMessageScrollOptions("smooth"), {
align: "center",
smooth: true,
});
assert.deepEqual(getVirtualMessageScrollOptions("auto"), {
align: "center",
smooth: false,
});
});

View File

@@ -0,0 +1,8 @@
export function getVirtualMessageScrollOptions(
behavior: ScrollBehavior | undefined,
) {
return {
align: "center" as const,
smooth: behavior === "smooth",
};
}

View File

@@ -933,15 +933,17 @@ test("unified channel search opens rows regardless of history position", async (
// Poll for the row matching `needle` to settle inside the timeline
// viewport, then return its placement + className. Polling is required
// because the find-bar -> active-match -> scrollIntoView path is async
// (state update, then a smooth scroll). Locator `toBeVisible` only
// checks DOM-visible (display/visibility), not in-viewport, so it
// can't be used as the wait condition for "the scroll completed".
// because the find-bar -> active-match -> virtualizer path is async
// (state update, then an indexed jump that may need to render the row).
// Locator `toBeVisible` only checks DOM-visible (display/visibility), not
// in-viewport, so it can't be used as the wait condition for "the scroll
// completed".
//
// Tolerance: 1px on each edge for sub-pixel rounding. The 5s budget
// accommodates browsers honoring smooth-scroll over long distances
// (initial scroll position is the bottom of a 200-message channel;
// the ALPHA row is ~180 rows up).
// Tolerance: 1px on each edge for sub-pixel rounding. The 5s budget leaves
// room for the virtualizer to realize and measure the distant ALPHA row
// (~180 rows above the initial bottom position). Distant unrendered search
// targets intentionally jump instantly; only already-rendered targets keep
// smooth scrolling for spatial context.
const waitForRowInViewport = async (needle: string) =>
timeline.evaluate((timelineEl, n) => {
return new Promise<{