From bd721e9eb2ed7d00cd445201e62fc5e6a17738e2 Mon Sep 17 00:00:00 2001 From: Rivet Date: Mon, 24 Aug 2026 13:01:49 -0700 Subject: [PATCH] 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 Signed-off-by: Rivet --- .../messages/ui/TimelineMessageList.tsx | 8 ++- .../ui/useAnchoredScroll.lifecycle.test.mjs | 65 +++++++++++++++++++ .../features/messages/ui/useAnchoredScroll.ts | 5 +- .../messages/ui/virtualMessageScroll.test.mjs | 15 +++++ .../messages/ui/virtualMessageScroll.ts | 8 +++ desktop/tests/e2e/scroll-history.spec.ts | 18 ++--- 6 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 desktop/src/features/messages/ui/virtualMessageScroll.test.mjs create mode 100644 desktop/src/features/messages/ui/virtualMessageScroll.ts diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index d7ef78ea04..793c731761 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -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; }, }; diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index cf2c575bfd..0e626fbe57 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -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 { diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 76179fe46c..ac9c7ae322 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -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"; diff --git a/desktop/src/features/messages/ui/virtualMessageScroll.test.mjs b/desktop/src/features/messages/ui/virtualMessageScroll.test.mjs new file mode 100644 index 0000000000..d5bc989d2d --- /dev/null +++ b/desktop/src/features/messages/ui/virtualMessageScroll.test.mjs @@ -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, + }); +}); diff --git a/desktop/src/features/messages/ui/virtualMessageScroll.ts b/desktop/src/features/messages/ui/virtualMessageScroll.ts new file mode 100644 index 0000000000..1950605d62 --- /dev/null +++ b/desktop/src/features/messages/ui/virtualMessageScroll.ts @@ -0,0 +1,8 @@ +export function getVirtualMessageScrollOptions( + behavior: ScrollBehavior | undefined, +) { + return { + align: "center" as const, + smooth: behavior === "smooth", + }; +} diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 0e1d4cfed6..dd0185fa85 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -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<{