From 99bbe266e8c4b2258aab02f2371a1324ee4ad58a Mon Sep 17 00:00:00 2001 From: luna Date: Thu, 30 Jul 2026 14:21:20 +0100 Subject: [PATCH 1/6] fix: prevent fast-scroll gaps --- README.md | 7 +++++++ demo/src/app.tsx | 1 + pnpm-lock.yaml | 12 ++++++------ pnpm-workspace.yaml | 4 ++-- src/index.tsx | 31 ++++++++++++++++++++++++++++- test/index.test.tsx | 48 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8321656..d2ebd29 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,12 @@ 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 avoid exposing the empty +spacer. + `initialRect` supplies an initial viewport height for server rendering: ```tsx diff --git a/demo/src/app.tsx b/demo/src/app.tsx index d64e5d4..46228f0 100644 --- a/demo/src/app.tsx +++ b/demo/src/app.tsx @@ -36,6 +36,7 @@ export function App(): ReactElement { getItemKey, initialRect: { height: 560 }, overscan: 6, + overscanPixels: 1_120, }); function prepend(): void { 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..c4090d7 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; @@ -61,6 +62,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 +115,7 @@ export function useVirtualizer( const scrollElementReference = useRef(null); const itemResizeObserverReference = useRef(null); const observedItemsReference = useRef(new Map()); + const renderedBoundsReference = useRef(undefined); const snapshot = useSyncExternalStore( instance.subscribe, @@ -184,11 +191,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,7 +217,19 @@ 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); + } }; scrollElement.addEventListener("scroll", onScroll, { passive: true }); const observer = diff --git a/test/index.test.tsx b/test/index.test.tsx index 72b1338..c124c75 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,7 @@ function fixedSize(): number { describe("useVirtualizer", () => { beforeEach(() => { ResizeObserverMock.instances.length = 0; + flushSyncMock.mockClear(); vi.stubGlobal("ResizeObserver", ResizeObserverMock); }); @@ -93,6 +103,7 @@ describe("useVirtualizer", () => { scrollElement.dispatchEvent(new Event("scroll")); }); + expect(flushSyncMock).toHaveBeenCalledOnce(); expect(result.current.visibleRange).toEqual({ endIndex: 14, startIndex: 10, @@ -100,6 +111,43 @@ 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("applies its initial offset when the container attaches", () => { const { result } = renderHook(() => useVirtualizer({ From 0ed7d208979b62758d348f34bdfea428775edf38 Mon Sep 17 00:00:00 2001 From: luna Date: Thu, 30 Jul 2026 14:27:29 +0100 Subject: [PATCH 2/6] fix: mask compositor checkerboarding --- README.md | 9 +++++++-- demo/src/app.tsx | 2 +- demo/src/styles.css | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d2ebd29..cf8348a 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,13 @@ 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 avoid exposing the empty -spacer. +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. `initialRect` supplies an initial viewport height for server rendering: diff --git a/demo/src/app.tsx b/demo/src/app.tsx index 46228f0..c4dc304 100644 --- a/demo/src/app.tsx +++ b/demo/src/app.tsx @@ -36,7 +36,7 @@ export function App(): ReactElement { getItemKey, initialRect: { height: 560 }, overscan: 6, - overscanPixels: 1_120, + overscanPixels: 4_480, }); function prepend(): void { diff --git a/demo/src/styles.css b/demo/src/styles.css index 7236d0f..baee4a2 100644 --- a/demo/src/styles.css +++ b/demo/src/styles.css @@ -136,19 +136,33 @@ h1 { } .viewport { + contain: strict; height: min(560px, 64vh); overflow: auto; overscroll-behavior: contain; } .spacer { + background: repeating-linear-gradient( + to bottom, + #f9fbf6 0, + #f9fbf6 57px, + #e1e6e2 57px, + #e1e6e2 58px, + #f4f7f1 58px, + #f4f7f1 115px, + #e1e6e2 115px, + #e1e6e2 116px + ); position: relative; width: 100%; } .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; From 24b6c5cdc5a739e8410faebe691157d3bf4556a1 Mon Sep 17 00:00:00 2001 From: luna Date: Thu, 30 Jul 2026 14:35:20 +0100 Subject: [PATCH 3/6] fix: render wheel targets before scrolling --- README.md | 16 +++++++ demo/src/app.tsx | 1 + demo/src/styles.css | 14 +++--- src/index.tsx | 46 ++++++++++++++++++- test/index.test.tsx | 105 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 172 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index cf8348a..3eb3e61 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,22 @@ 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 diff --git a/demo/src/app.tsx b/demo/src/app.tsx index c4dc304..498d7a0 100644 --- a/demo/src/app.tsx +++ b/demo/src/app.tsx @@ -37,6 +37,7 @@ export function App(): ReactElement { 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 baee4a2..3310829 100644 --- a/demo/src/styles.css +++ b/demo/src/styles.css @@ -136,13 +136,6 @@ h1 { } .viewport { - contain: strict; - height: min(560px, 64vh); - overflow: auto; - overscroll-behavior: contain; -} - -.spacer { background: repeating-linear-gradient( to bottom, #f9fbf6 0, @@ -154,6 +147,13 @@ h1 { #e1e6e2 115px, #e1e6e2 116px ); + contain: strict; + height: min(560px, 64vh); + overflow: auto; + overscroll-behavior: contain; +} + +.spacer { position: relative; width: 100%; } diff --git a/src/index.tsx b/src/index.tsx index c4090d7..29789f7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -35,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 { @@ -115,6 +121,7 @@ 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( @@ -125,8 +132,11 @@ export function useVirtualizer( 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; @@ -231,7 +241,38 @@ export function useVirtualizer( 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; + } + + event.preventDefault(); + preparingScrollReference.current = true; + try { + flushSync(() => + instance.setViewport(target, scrollElement.clientHeight), + ); + } finally { + preparingScrollReference.current = false; + } + scrollElement.scrollTop = instance.scrollOffset; + }; scrollElement.addEventListener("scroll", onScroll, { passive: true }); + if (options.synchronousWheelScrolling === true) { + scrollElement.addEventListener("wheel", onWheel, { passive: false }); + } const observer = typeof ResizeObserver === "undefined" ? undefined @@ -240,10 +281,11 @@ export function useVirtualizer( return () => { scrollElement.removeEventListener("scroll", onScroll); + scrollElement.removeEventListener("wheel", onWheel); observer?.disconnect(); scrollElementReference.current = null; }; - }, [instance, scrollElement]); + }, [instance, options.synchronousWheelScrolling, scrollElement]); useBrowserLayoutEffect(() => { const observer = itemResizeObserverReference.current; diff --git a/test/index.test.tsx b/test/index.test.tsx index c124c75..e913a09 100644 --- a/test/index.test.tsx +++ b/test/index.test.tsx @@ -81,7 +81,8 @@ function fixedSize(): number { describe("useVirtualizer", () => { beforeEach(() => { ResizeObserverMock.instances.length = 0; - flushSyncMock.mockClear(); + flushSyncMock.mockReset(); + flushSyncMock.mockImplementation((callback) => callback()); vi.stubGlobal("ResizeObserver", ResizeObserverMock); }); @@ -148,6 +149,94 @@ describe("useVirtualizer", () => { 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("applies its initial offset when the container attaches", () => { const { result } = renderHook(() => useVirtualizer({ @@ -165,6 +254,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); From 8d3b378e8559848bb0dad9c4413bd66d3f7e49c6 Mon Sep 17 00:00:00 2001 From: luna Date: Thu, 30 Jul 2026 14:36:57 +0100 Subject: [PATCH 4/6] fix: prepare non-cancelable wheel events --- src/index.tsx | 9 +++++++-- test/index.test.tsx | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 29789f7..415e53f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -258,7 +258,10 @@ export function useVirtualizer( return; } - event.preventDefault(); + const controlsScrollPosition = event.cancelable; + if (controlsScrollPosition) { + event.preventDefault(); + } preparingScrollReference.current = true; try { flushSync(() => @@ -267,7 +270,9 @@ export function useVirtualizer( } finally { preparingScrollReference.current = false; } - scrollElement.scrollTop = instance.scrollOffset; + if (controlsScrollPosition) { + scrollElement.scrollTop = instance.scrollOffset; + } }; scrollElement.addEventListener("scroll", onScroll, { passive: true }); if (options.synchronousWheelScrolling === true) { diff --git a/test/index.test.tsx b/test/index.test.tsx index e913a09..ad9a038 100644 --- a/test/index.test.tsx +++ b/test/index.test.tsx @@ -237,6 +237,27 @@ describe("useVirtualizer", () => { 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({ From 143e0ade3ccc2332cd9db710d284ff723c90b8d8 Mon Sep 17 00:00:00 2001 From: luna Date: Thu, 30 Jul 2026 14:53:16 +0100 Subject: [PATCH 5/6] fix: render controlled scroll targets first --- README.md | 5 ++ demo/src/app.tsx | 114 +++++++++++++++++++++++++++++++++++--------- demo/src/styles.css | 21 +++++++- src/index.tsx | 45 +++++++++++------ 4 files changed, 147 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 3eb3e61..714967c 100644 --- a/README.md +++ b/README.md @@ -99,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. This makes them suitable for controlled scrollbars +that need deterministic rendering. 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 498d7a0..20fbfeb 100644 --- a/demo/src/app.tsx +++ b/demo/src/app.tsx @@ -1,5 +1,13 @@ import { useVirtualizer } from "@lucid-softworks/react-virtualizer"; -import { useCallback, useState, type ReactElement } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type FormEvent, + type ReactElement, + type UIEvent, +} from "react"; interface Activity { readonly detail: string; @@ -39,6 +47,49 @@ export function App(): ReactElement { overscanPixels: 4_480, synchronousWheelScrolling: true, }); + const [viewportElement, setViewportElement] = useState( + null, + ); + const [viewportSize, setViewportSize] = useState(560); + const scrollbarReference = useRef(null); + const scrollElementRef = virtualizer.scrollElementRef; + const connectViewport = useCallback( + (element: HTMLDivElement | null): void => { + scrollElementRef(element); + setViewportElement(element); + }, + [scrollElementRef], + ); + + useEffect(() => { + if (viewportElement === null) { + return; + } + const updateSize = (): void => { + setViewportSize((current) => + current === viewportElement.clientHeight + ? current + : viewportElement.clientHeight, + ); + }; + updateSize(); + if (typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(updateSize); + observer.observe(viewportElement); + return () => observer.disconnect(); + }, [viewportElement]); + + function handleViewportScroll(event: UIEvent): void { + if (scrollbarReference.current !== null) { + scrollbarReference.current.value = String(event.currentTarget.scrollTop); + } + } + + function handleScrollbarInput(event: FormEvent): void { + virtualizer.scrollToOffset(Number(event.currentTarget.value)); + } function prepend(): void { setRows((current) => { @@ -123,28 +174,47 @@ export function App(): ReactElement { Measured size -
-
- {virtualizer.items.map((item) => { - const row = rows[item.index]!; - return ( -
- {item.index.toLocaleString()} - - {row.title} - {row.detail} - - {Math.round(item.size)}px -
- ); - })} +
+
+
+ {virtualizer.items.map((item) => { + const row = rows[item.index]!; + return ( +
+ {item.index.toLocaleString()} + + {row.title} + {row.detail} + + {Math.round(item.size)}px +
+ ); + })} +
+
diff --git a/demo/src/styles.css b/demo/src/styles.css index 3310829..8887b89 100644 --- a/demo/src/styles.css +++ b/demo/src/styles.css @@ -135,6 +135,12 @@ h1 { padding: 0.7rem 1.25rem; } +.viewport-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 22px; + height: min(560px, 64vh); +} + .viewport { background: repeating-linear-gradient( to bottom, @@ -148,9 +154,22 @@ h1 { #e1e6e2 116px ); contain: strict; - height: min(560px, 64vh); + height: 100%; overflow: auto; overscroll-behavior: contain; + scrollbar-width: none; +} + +.viewport::-webkit-scrollbar { + display: none; +} + +.scrollbar { + accent-color: #203b33; + height: 100%; + margin: 0; + width: 22px; + writing-mode: vertical-lr; } .spacer { diff --git a/src/index.tsx b/src/index.tsx index 415e53f..a19dda1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -130,6 +130,25 @@ 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) { @@ -262,17 +281,7 @@ export function useVirtualizer( if (controlsScrollPosition) { event.preventDefault(); } - preparingScrollReference.current = true; - try { - flushSync(() => - instance.setViewport(target, scrollElement.clientHeight), - ); - } finally { - preparingScrollReference.current = false; - } - if (controlsScrollPosition) { - scrollElement.scrollTop = instance.scrollOffset; - } + renderScrollTarget(scrollElement, target, controlsScrollPosition); }; scrollElement.addEventListener("scroll", onScroll, { passive: true }); if (options.synchronousWheelScrolling === true) { @@ -290,7 +299,12 @@ export function useVirtualizer( observer?.disconnect(); scrollElementReference.current = null; }; - }, [instance, options.synchronousWheelScrolling, scrollElement]); + }, [ + instance, + options.synchronousWheelScrolling, + renderScrollTarget, + scrollElement, + ]); useBrowserLayoutEffect(() => { const observer = itemResizeObserverReference.current; @@ -320,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( From a9c2694ff46315c3fa4ab84b4df5dbb505d2bc5f Mon Sep 17 00:00:00 2001 From: luna Date: Thu, 30 Jul 2026 14:57:28 +0100 Subject: [PATCH 6/6] fix: restore native demo scrollbar --- README.md | 6 +-- demo/src/app.tsx | 114 +++++++++----------------------------------- demo/src/styles.css | 21 +------- 3 files changed, 26 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 714967c..ff51ec7 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,6 @@ 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. This makes them suitable for controlled scrollbars -that need deterministic rendering. Smooth scrolling remains browser-native -because it traverses intermediate offsets over time. +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 20fbfeb..498d7a0 100644 --- a/demo/src/app.tsx +++ b/demo/src/app.tsx @@ -1,13 +1,5 @@ import { useVirtualizer } from "@lucid-softworks/react-virtualizer"; -import { - useCallback, - useEffect, - useRef, - useState, - type FormEvent, - type ReactElement, - type UIEvent, -} from "react"; +import { useCallback, useState, type ReactElement } from "react"; interface Activity { readonly detail: string; @@ -47,49 +39,6 @@ export function App(): ReactElement { overscanPixels: 4_480, synchronousWheelScrolling: true, }); - const [viewportElement, setViewportElement] = useState( - null, - ); - const [viewportSize, setViewportSize] = useState(560); - const scrollbarReference = useRef(null); - const scrollElementRef = virtualizer.scrollElementRef; - const connectViewport = useCallback( - (element: HTMLDivElement | null): void => { - scrollElementRef(element); - setViewportElement(element); - }, - [scrollElementRef], - ); - - useEffect(() => { - if (viewportElement === null) { - return; - } - const updateSize = (): void => { - setViewportSize((current) => - current === viewportElement.clientHeight - ? current - : viewportElement.clientHeight, - ); - }; - updateSize(); - if (typeof ResizeObserver === "undefined") { - return; - } - const observer = new ResizeObserver(updateSize); - observer.observe(viewportElement); - return () => observer.disconnect(); - }, [viewportElement]); - - function handleViewportScroll(event: UIEvent): void { - if (scrollbarReference.current !== null) { - scrollbarReference.current.value = String(event.currentTarget.scrollTop); - } - } - - function handleScrollbarInput(event: FormEvent): void { - virtualizer.scrollToOffset(Number(event.currentTarget.value)); - } function prepend(): void { setRows((current) => { @@ -174,47 +123,28 @@ export function App(): ReactElement { Measured size
-
-
-
- {virtualizer.items.map((item) => { - const row = rows[item.index]!; - return ( -
- {item.index.toLocaleString()} - - {row.title} - {row.detail} - - {Math.round(item.size)}px -
- ); - })} -
+
+
+ {virtualizer.items.map((item) => { + const row = rows[item.index]!; + return ( +
+ {item.index.toLocaleString()} + + {row.title} + {row.detail} + + {Math.round(item.size)}px +
+ ); + })}
-
diff --git a/demo/src/styles.css b/demo/src/styles.css index 8887b89..3310829 100644 --- a/demo/src/styles.css +++ b/demo/src/styles.css @@ -135,12 +135,6 @@ h1 { padding: 0.7rem 1.25rem; } -.viewport-shell { - display: grid; - grid-template-columns: minmax(0, 1fr) 22px; - height: min(560px, 64vh); -} - .viewport { background: repeating-linear-gradient( to bottom, @@ -154,22 +148,9 @@ h1 { #e1e6e2 116px ); contain: strict; - height: 100%; + height: min(560px, 64vh); overflow: auto; overscroll-behavior: contain; - scrollbar-width: none; -} - -.viewport::-webkit-scrollbar { - display: none; -} - -.scrollbar { - accent-color: #203b33; - height: 100%; - margin: 0; - width: 22px; - writing-mode: vertical-lr; } .spacer {