diff --git a/README.md b/README.md index 8321656..ff51ec7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ export function ActivityList({ rows }: { rows: readonly Activity[] }) { estimateSize: () => 48, getItemKey: (index) => rows[index]!.id, overscan: 5, + overscanPixels: 500, }); return ( @@ -51,6 +52,33 @@ The scroll container is observed for scrolling and resizing. Measured items use rest of the list. A stable `getItemKey` allows measurements and the visible anchor to survive prepends or reordering. +`overscanPixels` keeps a scroll-axis buffer mounted around the viewport. A +buffer of one or two viewport lengths helps native scrolling stay inside the +committed DOM range. If a high-velocity scroll still escapes that range, the +React adapter synchronously commits the new range to minimize how long the +browser is ahead of the rendered content. + +Browser-native threaded scrolling can outrun any finite JavaScript-rendered +range for a frame. Products that must never expose an empty surface should +give the spacer a lightweight placeholder background or layer; real rows can +cover it once committed. The bundled demo shows this pattern. + +For applications that prefer guaranteed wheel and trackpad rendering over +threaded scrolling, enable the opt-in synchronous path: + +```tsx +useVirtualizer({ + count: rows.length, + estimateSize: () => 48, + synchronousWheelScrolling: true, +}); +``` + +This renders the target range before updating `scrollTop`. It intentionally +moves wheel input onto the main thread, so it should be selected as a product +tradeoff rather than enabled by default. Touch, keyboard, and scrollbar input +remain browser-native. + `initialRect` supplies an initial viewport height for server rendering: ```tsx @@ -71,3 +99,8 @@ virtualizer.scrollToOffset(0, { behavior: "smooth" }); ``` Item alignment can be `start`, `center`, `end`, or `auto`. + +Automatic `scrollToOffset` and `scrollToIndex` calls commit their target range +before moving the element, preventing blank frames during programmatic jumps. +Smooth scrolling remains browser-native because it traverses intermediate +offsets over time. diff --git a/demo/src/app.tsx b/demo/src/app.tsx index d64e5d4..498d7a0 100644 --- a/demo/src/app.tsx +++ b/demo/src/app.tsx @@ -36,6 +36,8 @@ export function App(): ReactElement { getItemKey, initialRect: { height: 560 }, overscan: 6, + overscanPixels: 4_480, + synchronousWheelScrolling: true, }); function prepend(): void { diff --git a/demo/src/styles.css b/demo/src/styles.css index 7236d0f..3310829 100644 --- a/demo/src/styles.css +++ b/demo/src/styles.css @@ -136,6 +136,18 @@ h1 { } .viewport { + background: repeating-linear-gradient( + to bottom, + #f9fbf6 0, + #f9fbf6 57px, + #e1e6e2 57px, + #e1e6e2 58px, + #f4f7f1 58px, + #f4f7f1 115px, + #e1e6e2 115px, + #e1e6e2 116px + ); + contain: strict; height: min(560px, 64vh); overflow: auto; overscroll-behavior: contain; @@ -148,7 +160,9 @@ h1 { .row { align-items: center; + background: #f9fbf6; border-bottom: 1px solid #e1e6e2; + contain: layout paint style; left: 0; min-height: 58px; padding: 0.8rem 1.25rem; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 377d854..74acbe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,15 +5,15 @@ settings: excludeLinksFromLockfile: false overrides: - '@lucid-softworks/virtualizer': github:lucid-softworks/virtualizer#c76793d0e69945f6b136847c96c83af30ccbcc92 + '@lucid-softworks/virtualizer': github:lucid-softworks/virtualizer#a4cfa0614c5886031374de501450a361fd2936da importers: .: dependencies: '@lucid-softworks/virtualizer': - specifier: github:lucid-softworks/virtualizer#c76793d0e69945f6b136847c96c83af30ccbcc92 - version: https://codeload.github.com/lucid-softworks/virtualizer/tar.gz/c76793d0e69945f6b136847c96c83af30ccbcc92 + specifier: github:lucid-softworks/virtualizer#a4cfa0614c5886031374de501450a361fd2936da + version: https://codeload.github.com/lucid-softworks/virtualizer/tar.gz/a4cfa0614c5886031374de501450a361fd2936da devDependencies: '@lucid-softworks/oxfmt-config': specifier: ^0.1.1 @@ -344,8 +344,8 @@ packages: resolution: {integrity: sha512-PEEeHrK59OpQ0kahD2ugmjxqtd9AhYIeLwZ6yu9BAJOv9BcdfcRduA9Ntb7BOrlx3bzFYKp2sHs80sLO/CLEzw==} engines: {node: '>=22'} - '@lucid-softworks/virtualizer@https://codeload.github.com/lucid-softworks/virtualizer/tar.gz/c76793d0e69945f6b136847c96c83af30ccbcc92': - resolution: {gitHosted: true, integrity: sha512-430p34RNO+KduFN9LHOAE7bDBlv9LkpfOXK666wMe+qbI5Vx6ENaY2epn6axjQtOP7MZQEcluvjAc0hgikwkTA==, tarball: https://codeload.github.com/lucid-softworks/virtualizer/tar.gz/c76793d0e69945f6b136847c96c83af30ccbcc92} + '@lucid-softworks/virtualizer@https://codeload.github.com/lucid-softworks/virtualizer/tar.gz/a4cfa0614c5886031374de501450a361fd2936da': + resolution: {gitHosted: true, integrity: sha512-ZHRvMhhjkZQzhoEkPLI0At5lDkd6/M0exUbNjYXRhsNM4Rpdi+GdFNTRATWHKy6j+nbFx7YGogy/ou0TTHxM+A==, tarball: https://codeload.github.com/lucid-softworks/virtualizer/tar.gz/a4cfa0614c5886031374de501450a361fd2936da} version: 0.0.0 engines: {node: '>=22'} @@ -2882,7 +2882,7 @@ snapshots: '@lucid-softworks/tsconfig@0.1.0': {} - '@lucid-softworks/virtualizer@https://codeload.github.com/lucid-softworks/virtualizer/tar.gz/c76793d0e69945f6b136847c96c83af30ccbcc92': {} + '@lucid-softworks/virtualizer@https://codeload.github.com/lucid-softworks/virtualizer/tar.gz/a4cfa0614c5886031374de501450a361fd2936da': {} '@lucid-softworks/vitest-config@0.1.1(@vitest/coverage-v8@4.1.10)(vitest@4.1.10)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fcf8141..d5de894 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,12 +2,12 @@ packages: - "demo" allowBuilds: - "@lucid-softworks/virtualizer@https://codeload.github.com/lucid-softworks/virtualizer/tar.gz/c76793d0e69945f6b136847c96c83af30ccbcc92": true + "@lucid-softworks/virtualizer@https://codeload.github.com/lucid-softworks/virtualizer/tar.gz/a4cfa0614c5886031374de501450a361fd2936da": true minimumReleaseAge: 10080 trustPolicy: no-downgrade overrides: - "@lucid-softworks/virtualizer": "github:lucid-softworks/virtualizer#c76793d0e69945f6b136847c96c83af30ccbcc92" + "@lucid-softworks/virtualizer": "github:lucid-softworks/virtualizer#a4cfa0614c5886031374de501450a361fd2936da" trustPolicyExclude: - "semver@6.3.1" minimumReleaseAgeExclude: diff --git a/src/index.tsx b/src/index.tsx index 3782354..a19dda1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,6 +16,7 @@ import { useSyncExternalStore, type RefCallback, } from "react"; +import { flushSync } from "react-dom"; export interface InitialRect { readonly height: number; @@ -34,6 +35,12 @@ export interface UseVirtualizerOptions< * Defaults to true. */ readonly preserveAnchorOnChange?: boolean; + /** + * Render wheel and trackpad destinations before updating the element scroll + * position. Prevents compositor checkerboarding at the cost of moving wheel + * scrolling onto the main thread. Defaults to false. + */ + readonly synchronousWheelScrolling?: boolean; } export interface ReactScrollToOptions { @@ -61,6 +68,11 @@ export interface ReactVirtualizer { readonly visibleRange: VirtualRange | undefined; } +interface RenderedBounds { + readonly end: number; + readonly start: number; +} + const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; @@ -109,6 +121,8 @@ export function useVirtualizer( const scrollElementReference = useRef(null); const itemResizeObserverReference = useRef(null); const observedItemsReference = useRef(new Map()); + const preparingScrollReference = useRef(false); + const renderedBoundsReference = useRef(undefined); const snapshot = useSyncExternalStore( instance.subscribe, @@ -116,10 +130,32 @@ export function useVirtualizer( instance.getSnapshot, ); + const renderScrollTarget = useCallback( + ( + element: HTMLElement, + target: number, + updateScrollPosition: boolean, + ): void => { + preparingScrollReference.current = true; + try { + flushSync(() => instance.setViewport(target, element.clientHeight)); + } finally { + preparingScrollReference.current = false; + } + if (updateScrollPosition) { + setElementScroll(element, instance.scrollOffset, "auto"); + } + }, + [instance], + ); + const applyAdjustment = useCallback( (adjustment: number): void => { + if (adjustment === 0 || preparingScrollReference.current) { + return; + } const element = scrollElementReference.current; - if (element === null || adjustment === 0) { + if (element === null) { return; } element.scrollTop += adjustment; @@ -184,11 +220,21 @@ export function useVirtualizer( options.estimateSize, options.getItemKey, options.overscan, + options.overscanPixels, options.paddingEnd, options.paddingStart, options.preserveAnchorOnChange, ]); + useBrowserLayoutEffect(() => { + const firstItem = snapshot.items[0]; + const lastItem = snapshot.items.at(-1); + renderedBoundsReference.current = + firstItem === undefined || lastItem === undefined + ? undefined + : { end: lastItem.end, start: firstItem.start }; + }, [snapshot.items]); + useBrowserLayoutEffect(() => { scrollElementReference.current = scrollElement; if (scrollElement === null) { @@ -200,9 +246,47 @@ export function useVirtualizer( } instance.setViewport(scrollElement.scrollTop, scrollElement.clientHeight); const onScroll = (): void => { - instance.setViewport(scrollElement.scrollTop, scrollElement.clientHeight); + const offset = scrollElement.scrollTop; + const viewportSize = scrollElement.clientHeight; + const renderedBounds = renderedBoundsReference.current; + const escapedRenderedBounds = + renderedBounds === undefined || + offset < renderedBounds.start || + offset + viewportSize > renderedBounds.end; + + if (escapedRenderedBounds) { + flushSync(() => instance.setViewport(offset, viewportSize)); + } else { + instance.setViewport(offset, viewportSize); + } + }; + const onWheel = (event: WheelEvent): void => { + if (event.defaultPrevented || event.ctrlKey || event.deltaY === 0) { + return; + } + const delta = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? event.deltaY * 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? event.deltaY * scrollElement.clientHeight + : event.deltaY; + const target = instance.clampOffset( + Math.max(0, scrollElement.scrollTop + delta), + ); + if (target === scrollElement.scrollTop) { + return; + } + + const controlsScrollPosition = event.cancelable; + if (controlsScrollPosition) { + event.preventDefault(); + } + renderScrollTarget(scrollElement, target, controlsScrollPosition); }; scrollElement.addEventListener("scroll", onScroll, { passive: true }); + if (options.synchronousWheelScrolling === true) { + scrollElement.addEventListener("wheel", onWheel, { passive: false }); + } const observer = typeof ResizeObserver === "undefined" ? undefined @@ -211,10 +295,16 @@ export function useVirtualizer( return () => { scrollElement.removeEventListener("scroll", onScroll); + scrollElement.removeEventListener("wheel", onWheel); observer?.disconnect(); scrollElementReference.current = null; }; - }, [instance, scrollElement]); + }, [ + instance, + options.synchronousWheelScrolling, + renderScrollTarget, + scrollElement, + ]); useBrowserLayoutEffect(() => { const observer = itemResizeObserverReference.current; @@ -244,12 +334,13 @@ export function useVirtualizer( } const target = instance.clampOffset(offset); const behavior = scrollOptions.behavior ?? "auto"; - setElementScroll(scrollElement, target, behavior); if (behavior === "auto") { - instance.setViewport(target, scrollElement.clientHeight); + renderScrollTarget(scrollElement, target, true); + } else { + setElementScroll(scrollElement, target, behavior); } }, - [instance, scrollElement], + [instance, renderScrollTarget, scrollElement], ); const scrollToIndex = useCallback( diff --git a/test/index.test.tsx b/test/index.test.tsx index 72b1338..ad9a038 100644 --- a/test/index.test.tsx +++ b/test/index.test.tsx @@ -3,6 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useVirtualizer, type UseVirtualizerOptions } from "../src/index.js"; +const flushSyncMock = vi.hoisted(() => + vi.fn<(callback: () => void) => void>((callback) => callback()), +); + +vi.mock("react-dom", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, flushSync: flushSyncMock }; +}); + class ResizeObserverMock implements ResizeObserver { static readonly instances: ResizeObserverMock[] = []; readonly #callback: ResizeObserverCallback; @@ -72,6 +81,8 @@ function fixedSize(): number { describe("useVirtualizer", () => { beforeEach(() => { ResizeObserverMock.instances.length = 0; + flushSyncMock.mockReset(); + flushSyncMock.mockImplementation((callback) => callback()); vi.stubGlobal("ResizeObserver", ResizeObserverMock); }); @@ -93,6 +104,7 @@ describe("useVirtualizer", () => { scrollElement.dispatchEvent(new Event("scroll")); }); + expect(flushSyncMock).toHaveBeenCalledOnce(); expect(result.current.visibleRange).toEqual({ endIndex: 14, startIndex: 10, @@ -100,6 +112,152 @@ describe("useVirtualizer", () => { expect(result.current.totalSize).toBe(2000); }); + it("does not force a commit while scrolling inside rendered overscan", () => { + const { result } = renderHook(() => + useVirtualizer({ + ...baseOptions, + overscanPixels: 100, + }), + ); + const scrollElement = createElement(100); + act(() => result.current.scrollElementRef(scrollElement)); + + act(() => { + scrollElement.scrollTop = 40; + scrollElement.dispatchEvent(new Event("scroll")); + }); + + expect(flushSyncMock).not.toHaveBeenCalled(); + expect(result.current.visibleRange?.startIndex).toBe(2); + }); + + it("forces a new range when fast scrolling escapes in either direction", () => { + const { result } = renderHook(() => useVirtualizer(baseOptions)); + const scrollElement = createElement(100); + act(() => result.current.scrollElementRef(scrollElement)); + + act(() => { + scrollElement.scrollTop = 1_000; + scrollElement.dispatchEvent(new Event("scroll")); + }); + act(() => { + scrollElement.scrollTop = 0; + scrollElement.dispatchEvent(new Event("scroll")); + }); + + expect(flushSyncMock).toHaveBeenCalledTimes(2); + expect(result.current.visibleRange?.startIndex).toBe(0); + }); + + it.each([ + [WheelEvent.DOM_DELTA_PIXEL, 10, 10], + [WheelEvent.DOM_DELTA_LINE, 2, 32], + [WheelEvent.DOM_DELTA_PAGE, 1, 100], + ])( + "renders delta mode %i before synchronously scrolling", + (deltaMode, deltaY, expectedOffset) => { + const { result } = renderHook(() => + useVirtualizer({ + ...baseOptions, + synchronousWheelScrolling: true, + }), + ); + const scrollElement = createElement(100); + act(() => result.current.scrollElementRef(scrollElement)); + flushSyncMock.mockClear(); + const event = new WheelEvent("wheel", { + cancelable: true, + deltaMode, + deltaY, + }); + + act(() => scrollElement.dispatchEvent(event)); + + expect(event.defaultPrevented).toBe(true); + expect(scrollElement.scrollTop).toBe(expectedOffset); + expect(flushSyncMock).toHaveBeenCalledOnce(); + }, + ); + + it("leaves non-scrolling and modified wheel input native", () => { + const { result } = renderHook(() => + useVirtualizer({ + ...baseOptions, + synchronousWheelScrolling: true, + }), + ); + const scrollElement = createElement(100); + act(() => result.current.scrollElementRef(scrollElement)); + const events = [ + new WheelEvent("wheel", { cancelable: true, deltaY: 0 }), + new WheelEvent("wheel", { + cancelable: true, + ctrlKey: true, + deltaY: 20, + }), + new WheelEvent("wheel", { cancelable: true, deltaY: -20 }), + ]; + const alreadyHandled = new WheelEvent("wheel", { + cancelable: true, + deltaY: 20, + }); + alreadyHandled.preventDefault(); + events.push(alreadyHandled); + + for (const event of events) { + act(() => scrollElement.dispatchEvent(event)); + } + + expect(scrollElement.scrollTop).toBe(0); + expect(flushSyncMock).not.toHaveBeenCalled(); + }); + + it("applies measurements after preparing a synchronous wheel range", () => { + const { result } = renderHook(() => + useVirtualizer({ + ...baseOptions, + synchronousWheelScrolling: true, + }), + ); + const scrollElement = createElement(100); + act(() => result.current.scrollElementRef(scrollElement)); + const itemBeforeTarget = createElement(30, 0); + flushSyncMock.mockImplementationOnce((callback) => { + callback(); + result.current.measureElement(itemBeforeTarget); + }); + const event = new WheelEvent("wheel", { + cancelable: true, + deltaY: 100, + }); + + act(() => scrollElement.dispatchEvent(event)); + + expect(scrollElement.scrollTop).toBe(110); + expect(result.current.visibleRange?.startIndex).toBe(5); + }); + + it("prepares non-cancelable wheel destinations without moving scrollTop", () => { + const { result } = renderHook(() => + useVirtualizer({ + ...baseOptions, + synchronousWheelScrolling: true, + }), + ); + const scrollElement = createElement(100); + act(() => result.current.scrollElementRef(scrollElement)); + const event = new WheelEvent("wheel", { + cancelable: false, + deltaY: 40, + }); + + act(() => scrollElement.dispatchEvent(event)); + + expect(event.defaultPrevented).toBe(false); + expect(scrollElement.scrollTop).toBe(0); + expect(result.current.visibleRange?.startIndex).toBe(2); + }); + it("applies its initial offset when the container attaches", () => { const { result } = renderHook(() => useVirtualizer({ @@ -117,6 +275,20 @@ describe("useVirtualizer", () => { }); }); + it("accepts an anchor correction before its container attaches", () => { + const { result } = renderHook(() => + useVirtualizer({ + ...baseOptions, + initialOffset: 40, + initialRect: { height: 40 }, + }), + ); + + act(() => result.current.measureElement(createElement(30, 0))); + + expect(result.current.visibleRange?.startIndex).toBe(2); + }); + it("measures items and reacts to later resizes", () => { const { result } = renderHook(() => useVirtualizer(baseOptions)); const scrollElement = createElement(100);