diff --git a/example-web/src/examples/PrependLargeItemsJumpExample.tsx b/example-web/src/examples/PrependLargeItemsJumpExample.tsx index a57ecbec..85f9b89d 100644 --- a/example-web/src/examples/PrependLargeItemsJumpExample.tsx +++ b/example-web/src/examples/PrependLargeItemsJumpExample.tsx @@ -13,20 +13,91 @@ type JumpEvent = { at: number; deltaPx: number; fromPx: number; + id: number; toPx: number; }; +type Config = { + jumpDetectorMultiplier: number; + multiplier: number; + pageSize: number; +}; + +type Props = { + useWindowScroll?: boolean; +}; + const COPY_VARIANTS = [ "Compact row.", - "This row is intentionally longer and includes enough words to wrap on most desktop resolutions. It helps demonstrate how virtualization handles mixed measured heights while the scroll container drives the list.", + "This row is intentionally longer and includes enough words to wrap on most desktop resolutions. It helps demonstrate how virtualization handles mixed measured heights while prepend loading keeps the viewport anchored.", "Medium-length example text with a second sentence so item height differs from compact rows.", "Very long row copy: when this row renders, it should span multiple lines even on large monitors because the sentence keeps going with descriptive details about prepend loading, maintainVisibleContentPosition, and stable positioning during rapid wheel movement.", "Another short row.", "Long variant with punctuation and structure. First, it adds detail. Second, it continues with additional context about list behavior under dynamic content. Third, it closes with one more sentence to force a clearly taller cell.", ]; const ROW_COLORS = ["#111827", "#7c2d12", "#14532d", "#1d4ed8", "#7e22ce", "#9a3412"]; -const PAGE_SIZE = 5; const INITIAL_COUNT = 10; +const DEFAULT_PAGE_SIZE = 10; +const DEFAULT_MULTIPLIER = 50; +const DEFAULT_JUMP_DETECTOR_MULTIPLIER = 100; +const MIN_PAGE_SIZE = 1; +const MAX_PAGE_SIZE = 30; +const MIN_MULTIPLIER = 1; +const MAX_MULTIPLIER = 50; +const MIN_JUMP_DETECTOR_MULTIPLIER = 1; +const MAX_JUMP_DETECTOR_MULTIPLIER = 300; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function getNumberParam(searchParams: URLSearchParams, key: string, fallback: number, min: number, max: number) { + const value = searchParams.get(key); + if (value === null || value === "") { + return fallback; + } + + const raw = Number(value); + return Number.isFinite(raw) ? clamp(raw, min, max) : fallback; +} + +function readConfigFromUrl(): Config { + if (typeof window === "undefined") { + return { + jumpDetectorMultiplier: DEFAULT_JUMP_DETECTOR_MULTIPLIER, + multiplier: DEFAULT_MULTIPLIER, + pageSize: DEFAULT_PAGE_SIZE, + }; + } + + const searchParams = new URLSearchParams(window.location.search); + return { + jumpDetectorMultiplier: getNumberParam( + searchParams, + "jumpMultiplier", + DEFAULT_JUMP_DETECTOR_MULTIPLIER, + MIN_JUMP_DETECTOR_MULTIPLIER, + MAX_JUMP_DETECTOR_MULTIPLIER, + ), + multiplier: getNumberParam(searchParams, "multiplier", DEFAULT_MULTIPLIER, MIN_MULTIPLIER, MAX_MULTIPLIER), + pageSize: getNumberParam(searchParams, "pageSize", DEFAULT_PAGE_SIZE, MIN_PAGE_SIZE, MAX_PAGE_SIZE), + }; +} + +function writeConfigToUrl(config: Config) { + if (typeof window === "undefined") { + return; + } + + const searchParams = new URLSearchParams(window.location.search); + searchParams.set("pageSize", String(config.pageSize)); + searchParams.set("multiplier", String(config.multiplier)); + searchParams.set("jumpMultiplier", String(config.jumpDetectorMultiplier)); + + const nextSearch = searchParams.toString(); + const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ""}${window.location.hash}`; + window.history.replaceState(null, "", nextUrl); +} function createItems(fromInclusive: number, count: number, sizeMultiplier: number): DemoItem[] { return Array.from({ length: count }, (_, offset) => { @@ -42,25 +113,42 @@ function createItems(fromInclusive: number, count: number, sizeMultiplier: numbe }); } -function getScrollFromBottom(node: HTMLElement | null): number { +function getScrollFromBottom(node: HTMLElement | null, useWindowScroll: boolean): number { if (!node) { return 0; } + if (useWindowScroll) { + if (typeof window === "undefined") { + return 0; + } + + return node.scrollHeight - window.innerHeight - window.scrollY; + } + return node.scrollHeight - node.clientHeight - node.scrollTop; } -function useScrollJumpDetector(node: HTMLElement | null, thresholdPx: number) { +function useScrollJumpDetector( + node: HTMLElement | null, + thresholdPx: number, + useWindowScroll: boolean, + resetKey: number, +) { const [events, setEvents] = React.useState([]); const [pxFromBottom, setPxFromBottom] = React.useState(0); + const [totalJumpCount, setTotalJumpCount] = React.useState(0); + const nextEventIdRef = React.useRef(0); const prevRef = React.useRef(null); const rafRef = React.useRef(0); React.useEffect(() => { prevRef.current = null; setEvents([]); - setPxFromBottom(getScrollFromBottom(node)); - }, [node, thresholdPx]); + setPxFromBottom(getScrollFromBottom(node, useWindowScroll)); + setTotalJumpCount(0); + nextEventIdRef.current = 0; + }, [node, thresholdPx, useWindowScroll, resetKey]); React.useEffect(() => { if (!node) { @@ -70,19 +158,23 @@ function useScrollJumpDetector(node: HTMLElement | null, thresholdPx: number) { const sample = () => { rafRef.current = 0; - const next = getScrollFromBottom(node); + const next = getScrollFromBottom(node, useWindowScroll); setPxFromBottom(next); const prev = prevRef.current; if (prev !== null) { const delta = Math.abs(next - prev); if (delta >= thresholdPx) { + const eventId = nextEventIdRef.current; + nextEventIdRef.current += 1; + setTotalJumpCount((count) => count + 1); setEvents((current) => [ { at: Date.now(), deltaPx: delta, fromPx: prev, + id: eventId, toPx: next, }, ...current, @@ -101,22 +193,32 @@ function useScrollJumpDetector(node: HTMLElement | null, thresholdPx: number) { }; sample(); - node.addEventListener("scroll", onScrollOrResize, { passive: true }); + + if (useWindowScroll) { + window.addEventListener("scroll", onScrollOrResize, { passive: true }); + } else { + node.addEventListener("scroll", onScrollOrResize, { passive: true }); + } window.addEventListener("resize", onScrollOrResize); return () => { - node.removeEventListener("scroll", onScrollOrResize); + if (useWindowScroll) { + window.removeEventListener("scroll", onScrollOrResize); + } else { + node.removeEventListener("scroll", onScrollOrResize); + } window.removeEventListener("resize", onScrollOrResize); if (rafRef.current) { cancelAnimationFrame(rafRef.current); } }; - }, [node, thresholdPx]); + }, [node, thresholdPx, useWindowScroll, resetKey]); - return { events, pxFromBottom }; + return { events, pxFromBottom, totalJumpCount }; } -export default function PrependLargeItemsJumpExample() { +export default function PrependLargeItemsJumpExample({ useWindowScroll = false }: Props) { + const initialConfig = React.useMemo(readConfigFromUrl, []); const listRef = React.useRef(null); const isReadyRef = React.useRef(false); const isPrependingRef = React.useRef(false); @@ -124,10 +226,17 @@ export default function PrependLargeItemsJumpExample() { const [exampleVersion, setExampleVersion] = React.useState(0); const [prependCount, setPrependCount] = React.useState(0); const [scrollNode, setScrollNode] = React.useState(null); - const [sizeMultiplier, setSizeMultiplier] = React.useState(200); - const [data, setData] = React.useState(() => createItems(0, INITIAL_COUNT, 40)); - const jumpThreshold = Math.max(600, sizeMultiplier * 140); - const { events, pxFromBottom } = useScrollJumpDetector(scrollNode, jumpThreshold); + const [pageSize, setPageSize] = React.useState(initialConfig.pageSize); + const [multiplier, setMultiplier] = React.useState(initialConfig.multiplier); + const [jumpDetectorMultiplier, setJumpDetectorMultiplier] = React.useState(initialConfig.jumpDetectorMultiplier); + const [data, setData] = React.useState(() => createItems(0, INITIAL_COUNT, initialConfig.multiplier)); + const jumpThreshold = Math.max(600, multiplier * jumpDetectorMultiplier); + const { events, pxFromBottom, totalJumpCount } = useScrollJumpDetector( + scrollNode, + jumpThreshold, + useWindowScroll, + exampleVersion, + ); const resetExample = React.useCallback((nextMultiplier: number) => { oldestIdRef.current = 0; @@ -135,27 +244,42 @@ export default function PrependLargeItemsJumpExample() { isPrependingRef.current = false; setScrollNode(null); setPrependCount(0); - setSizeMultiplier(nextMultiplier); setData(createItems(0, INITIAL_COUNT, nextMultiplier)); setExampleVersion((value) => value + 1); }, []); + React.useEffect(() => { + writeConfigToUrl({ jumpDetectorMultiplier, multiplier, pageSize }); + }, [jumpDetectorMultiplier, multiplier, pageSize]); + + React.useEffect(() => { + resetExample(multiplier); + }, [multiplier, pageSize, resetExample]); + + React.useEffect(() => { + if (!useWindowScroll || typeof window === "undefined") { + return; + } + + window.scrollTo({ behavior: "auto", top: 0 }); + }, [exampleVersion, useWindowScroll]); + const prependPage = React.useCallback(() => { if (!isReadyRef.current || isPrependingRef.current) { return; } isPrependingRef.current = true; - const nextStart = oldestIdRef.current - PAGE_SIZE; + const nextStart = oldestIdRef.current - pageSize; oldestIdRef.current = nextStart; setPrependCount((value) => value + 1); - setData((current) => [...createItems(nextStart, PAGE_SIZE, sizeMultiplier), ...current]); + setData((current) => [...createItems(nextStart, pageSize, multiplier), ...current]); requestAnimationFrame(() => { isPrependingRef.current = false; }); - }, [sizeMultiplier]); + }, [multiplier, pageSize]); React.useEffect(() => { let raf = 0; @@ -177,72 +301,28 @@ export default function PrependLargeItemsJumpExample() { }; }, [exampleVersion]); - return ( -
-
-
-

Oversized prepend jump reproduction

-

- This is a scroll-container example that starts at the end like a chat. When{" "} - onStartReached prepends older messages, oversized rows can cause large scroll jumps - because the current viewport sits inside a row whose top edge is already above the viewport. -

-

- Use the presets below, scroll toward the top, and watch the jump log. Small rows usually stay - stable; oversized rows reproduce the bug. -

-
- -
- - - - - - size multiplier: {sizeMultiplier} - - - prepends: {prependCount} - -
+ const scrollLabel = useWindowScroll ? "page bottom" : "list bottom"; + const modeLabel = useWindowScroll ? "window scroll" : "scroll container"; + const outerStyle: React.CSSProperties = useWindowScroll + ? { alignItems: "flex-start", display: "flex", gap: 16, paddingBottom: 32 } + : { display: "flex", flex: 1, gap: 16, minHeight: 0 }; + const leftColumnStyle: React.CSSProperties = useWindowScroll + ? { display: "flex", flex: 1, flexDirection: "column", gap: 12, minWidth: 0 } + : { display: "flex", flex: 1, flexDirection: "column", gap: 12, minHeight: 0, minWidth: 0 }; + return ( +
+
@@ -276,13 +356,15 @@ export default function PrependLargeItemsJumpExample() {
{item.text}
)} - style={{ flex: 1, minHeight: 0 }} + style={useWindowScroll ? { width: "100%" } : { flex: 1, minHeight: 0 }} + useWindowScroll={useWindowScroll} />