Skip to content

virtual Improvements (Dynamic Item Sizes, Fix Broken Reactivity) #803

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
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
25 changes: 20 additions & 5 deletions packages/virtual/dev/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ import { VirtualList } from "../src/index.jsx";

const intl = new Intl.NumberFormat();

const items = new Array(100_000).fill(0).map((_, i) => i);

const items = new Array(100_000).fill(0).map((_, i) => [i, Math.random() * 72 + 24]);
const clampRange = (min: number, max: number, v: number) => (v < min ? min : v > max ? max : v);

const App: Component = () => {
const [dynamicSize, setDynamicSize] = createSignal(true);
const [listLength, setListLength] = createSignal(100_000);
const [overscanCount, setOverscanCount] = createSignal(5);
const [rootHeight, setRootHeight] = createSignal(240);
const [rowHeight, setRowHeight] = createSignal(24);
let scrollToItem!: (itemIndex: number) => void;

return (
<div class="box-border flex min-h-screen w-full flex-col space-y-4 bg-gray-800 p-24 text-white">
<div class="grid w-full grid-cols-4 bg-white p-4 text-gray-800 shadow-md">
<div>
<button onClick={() => setDynamicSize(!dynamicSize())}>
{dynamicSize() ? "Dynamic Mode" : "Static Mode"}
</button>
</div>
<div>
<DemoControl
label="Number of rows"
Expand Down Expand Up @@ -69,9 +75,12 @@ const App: Component = () => {
fallback={<div>no items</div>}
overscanCount={overscanCount()}
rootHeight={rootHeight()}
rowHeight={rowHeight()}
rowHeight={dynamicSize() ? item => item[1]! : rowHeight()}
setScrollToItem={fn => (scrollToItem = fn)}
>
{item => <VirtualListItem item={item} height={rowHeight()} />}
{item => (
<VirtualListItem item={item[0]!} height={dynamicSize() ? item[1]! : rowHeight()} />
)}
</VirtualList>
</div>
</div>
Expand Down Expand Up @@ -122,7 +131,13 @@ const VirtualListItem: Component<VirtualListItemProps> = props => {
});

return (
<div style={{ height: `${props.height}px` }} class="align-center flex">
<div
style={{
height: `${props.height}px`,
"background-color": `rgb(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255})`,
}}
class="align-center flex"
>
{intl.format(props.item)}
</div>
);
Expand Down
97 changes: 74 additions & 23 deletions packages/virtual/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { For, createSignal } from "solid-js";
import { For, createMemo, createSignal } from "solid-js";
import type { Accessor, JSX } from "solid-js";
import { access } from "@solid-primitives/utils";
import type { MaybeAccessor } from "@solid-primitives/utils";

type VirtualListConfig<T extends readonly any[]> = {
items: MaybeAccessor<T | undefined | null | false>;
rootHeight: MaybeAccessor<number>;
rowHeight: MaybeAccessor<number>;
overscanCount?: MaybeAccessor<number>;
rowHeight: MaybeAccessor<number | ((row: T[number], index: number) => number)>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An accessor that returns a function is a bit stupid. Shouldn't it be MaybeAccessor<number> | ((row: T[number], index: number) => number)?

};

type VirtualListReturn<T extends readonly any[]> = [
Expand All @@ -17,6 +17,11 @@ type VirtualListReturn<T extends readonly any[]> = [
visibleItems: T;
}>,
onScroll: (e: Event) => void,
{
getFirstIdx: () => number;
getLastIdx: () => number;
scrollToItem: (itemIndex: number, scrollContainer: HTMLElement) => void;
},
];

/**
Expand All @@ -28,47 +33,87 @@ type VirtualListReturn<T extends readonly any[]> = [
* @param overscanCount the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling
* @returns {VirtualListReturn} to use in the list's jsx
*/
export function createVirtualList<T extends readonly any[]>({
items,
rootHeight,
rowHeight,
overscanCount,
}: VirtualListConfig<T>): VirtualListReturn<T> {
items = access(items) || ([] as any as T);
rootHeight = access(rootHeight);
rowHeight = access(rowHeight);
overscanCount = access(overscanCount) || 1;
export function createVirtualList<T extends readonly any[]>(
cfg: VirtualListConfig<T>,
): VirtualListReturn<T> {
const items = () => access(cfg.items) || ([] as any as T);
const overscanCount = () => access(cfg.overscanCount) || 1;

const [offset, setOffset] = createSignal(0);

const getFirstIdx = () => Math.max(0, Math.floor(offset() / rowHeight) - overscanCount);
const rowOffsets = createMemo(() => {
let offset = 0;
return items().map((item, i) => {
const current = offset;
const rowHeight = access(cfg.rowHeight);

offset += typeof rowHeight === "function" ? rowHeight(item, i) : rowHeight;
return current;
});
});

// Binary Search for performance
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole function is already pretty complex without the binary search being included inside. I would like to ask you to extract from the function and use offsets as another parameter.

const findRowIndexAtOffset = (offset: number) => {
const offsets = rowOffsets();

let lo = 0,
hi = offsets.length - 1,
mid: number;
while (lo <= hi) {
mid = (lo + hi) >>> 1;
if (offsets[mid]! > offset) {
hi = mid - 1;
} else {
lo = mid + 1;
}
}
return lo;
};

const getFirstIdx = () => Math.max(0, findRowIndexAtOffset(offset()) - overscanCount());

// const getFirstIdx = () => Math.max(0, Math.floor(offset() / rowHeight) - overscanCount);

const getLastIdx = () =>
Math.min(
items.length,
Math.floor(offset() / rowHeight) + Math.ceil(rootHeight / rowHeight) + overscanCount,
items().length,
findRowIndexAtOffset(offset() + access(cfg.rootHeight)) + overscanCount(),
);

// const getLastIdx = () =>
// Math.min(
// items.length,
// Math.floor(offset() / rowHeight) + Math.ceil(rootHeight / rowHeight) + overscanCount,
// );

return [
() => ({
containerHeight: items.length * rowHeight,
viewerTop: getFirstIdx() * rowHeight,
visibleItems: items.slice(getFirstIdx(), getLastIdx()) as unknown as T,
containerHeight: items().length !== 0 ? rowOffsets()[items().length - 1]! : 0,
viewerTop: rowOffsets()[getFirstIdx()]!,
visibleItems: items().slice(getFirstIdx(), getLastIdx()) as unknown as T,
}),
e => {
// @ts-expect-error
if (e.target?.scrollTop !== undefined) setOffset(e.target.scrollTop);
},
{
getFirstIdx,
getLastIdx,
scrollToItem: (itemIndex: number, scrollContainer: HTMLElement) => {
scrollContainer.scrollTop = rowOffsets()[itemIndex]!;
},
},
];
}

type VirtualListProps<T extends readonly any[], U extends JSX.Element> = {
children: (item: T[number], index: Accessor<number>) => U;
children: (item: T[number], index: Accessor<number>, rawIndex: Accessor<number>) => U;
each: T | undefined | null | false;
fallback?: JSX.Element;
overscanCount?: number;
rowHeight: number | ((row: T[number], index: number) => number);
rootHeight: number;
rowHeight: number;
setScrollToItem: (scrollToItem: (itemIndex: number) => void) => void;
};

/**
Expand All @@ -79,21 +124,26 @@ type VirtualListProps<T extends readonly any[], U extends JSX.Element> = {
* @param fallback the optional fallback to display if the list of items to display is empty
* @param overscanCount the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling
* @param rootHeight the height of the root element of the virtualizedList itself
* @param rowHeight the height of individual rows in the virtualizedList
* @param rowHeight the height of individual rows in the virtualizedList—can be static if just a number is provided, or dynamic if a callback is passed
* @returns virtualized list component
*/
export function VirtualList<T extends readonly any[], U extends JSX.Element>(
props: VirtualListProps<T, U>,
): JSX.Element {
const [virtual, onScroll] = createVirtualList({
const [virtual, onScroll, { scrollToItem, getFirstIdx }] = createVirtualList({
items: () => props.each,
rootHeight: () => props.rootHeight,
rowHeight: () => props.rowHeight,
overscanCount: () => props.overscanCount || 1,
});

props.setScrollToItem((itemIndex: number) => scrollToItem(itemIndex, scrollContainer));

let scrollContainer!: HTMLDivElement;

return (
<div
ref={scrollContainer}
style={{
overflow: "auto",
height: `${props.rootHeight}px`,
Expand All @@ -111,10 +161,11 @@ export function VirtualList<T extends readonly any[], U extends JSX.Element>(
style={{
position: "absolute",
top: `${virtual().viewerTop}px`,
width: "inherit",
}}
>
<For fallback={props.fallback} each={virtual().visibleItems}>
{props.children}
{(item, index) => props.children(item, () => getFirstIdx() + index(), index)}
</For>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion site/src/primitives/DocumentHydrationHelper.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getRequestEvent, HydrationScript, NoHydration } from "solid-js/web";
import { Asset, PageEvent } from "@solidjs/start/server";
import type { Asset, PageEvent } from "@solidjs/start/server";
import type { JSX } from "solid-js";

const assetMap = {
Expand Down