mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
8
desktop/src/features/messages/ui/virtualMessageScroll.ts
Normal file
8
desktop/src/features/messages/ui/virtualMessageScroll.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function getVirtualMessageScrollOptions(
|
||||
behavior: ScrollBehavior | undefined,
|
||||
) {
|
||||
return {
|
||||
align: "center" as const,
|
||||
smooth: behavior === "smooth",
|
||||
};
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user