Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function ActivityList({ rows }: { rows: readonly Activity[] }) {
estimateSize: () => 48,
getItemKey: (index) => rows[index]!.id,
overscan: 5,
overscanPixels: 500,
});

return (
Expand Down Expand Up @@ -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
Expand All @@ -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.
2 changes: 2 additions & 0 deletions demo/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export function App(): ReactElement {
getItemKey,
initialRect: { height: 560 },
overscan: 6,
overscanPixels: 4_480,
synchronousWheelScrolling: true,
});

function prepend(): void {
Expand Down
14 changes: 14 additions & 0 deletions demo/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
103 changes: 97 additions & 6 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
useSyncExternalStore,
type RefCallback,
} from "react";
import { flushSync } from "react-dom";

export interface InitialRect {
readonly height: number;
Expand All @@ -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 {
Expand Down Expand Up @@ -61,6 +68,11 @@ export interface ReactVirtualizer<TKey extends VirtualItemKey = number> {
readonly visibleRange: VirtualRange | undefined;
}

interface RenderedBounds {
readonly end: number;
readonly start: number;
}

const useBrowserLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;

Expand Down Expand Up @@ -109,17 +121,41 @@ export function useVirtualizer<TKey extends VirtualItemKey = number>(
const scrollElementReference = useRef<HTMLElement | null>(null);
const itemResizeObserverReference = useRef<ResizeObserver | null>(null);
const observedItemsReference = useRef(new Map<number, HTMLElement>());
const preparingScrollReference = useRef(false);
const renderedBoundsReference = useRef<RenderedBounds | undefined>(undefined);

const snapshot = useSyncExternalStore(
instance.subscribe,
instance.getSnapshot,
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;
Expand Down Expand Up @@ -184,11 +220,21 @@ export function useVirtualizer<TKey extends VirtualItemKey = number>(
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) {
Expand All @@ -200,9 +246,47 @@ export function useVirtualizer<TKey extends VirtualItemKey = number>(
}
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
Expand All @@ -211,10 +295,16 @@ export function useVirtualizer<TKey extends VirtualItemKey = number>(

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;
Expand Down Expand Up @@ -244,12 +334,13 @@ export function useVirtualizer<TKey extends VirtualItemKey = number>(
}
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(
Expand Down
Loading
Loading