diff --git a/packages/virtual/dev/index.tsx b/packages/virtual/dev/index.tsx index bb715cc8f..a36266b40 100644 --- a/packages/virtual/dev/index.tsx +++ b/packages/virtual/dev/index.tsx @@ -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 (
+
+ +
{ fallback={
no items
} overscanCount={overscanCount()} rootHeight={rootHeight()} - rowHeight={rowHeight()} + rowHeight={dynamicSize() ? item => item[1]! : rowHeight()} + setScrollToItem={fn => (scrollToItem = fn)} > - {item => } + {item => ( + + )}
@@ -122,7 +131,13 @@ const VirtualListItem: Component = props => { }); return ( -
+
{intl.format(props.item)}
); diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index dbebd2b4c..b3b573e3b 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -1,4 +1,4 @@ -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"; @@ -6,8 +6,8 @@ import type { MaybeAccessor } from "@solid-primitives/utils"; type VirtualListConfig = { items: MaybeAccessor; rootHeight: MaybeAccessor; - rowHeight: MaybeAccessor; overscanCount?: MaybeAccessor; + rowHeight: MaybeAccessor number)>; }; type VirtualListReturn = [ @@ -17,6 +17,11 @@ type VirtualListReturn = [ visibleItems: T; }>, onScroll: (e: Event) => void, + { + getFirstIdx: () => number; + getLastIdx: () => number; + scrollToItem: (itemIndex: number, scrollContainer: HTMLElement) => void; + }, ]; /** @@ -28,47 +33,87 @@ type VirtualListReturn = [ * @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({ - items, - rootHeight, - rowHeight, - overscanCount, -}: VirtualListConfig): VirtualListReturn { - items = access(items) || ([] as any as T); - rootHeight = access(rootHeight); - rowHeight = access(rowHeight); - overscanCount = access(overscanCount) || 1; +export function createVirtualList( + cfg: VirtualListConfig, +): VirtualListReturn { + 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 + 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 = { - children: (item: T[number], index: Accessor) => U; + children: (item: T[number], index: Accessor, rawIndex: Accessor) => 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; }; /** @@ -79,21 +124,26 @@ type VirtualListProps = { * @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( props: VirtualListProps, ): 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 (
( style={{ position: "absolute", top: `${virtual().viewerTop}px`, + width: "inherit", }} > - {props.children} + {(item, index) => props.children(item, () => getFirstIdx() + index(), index)}
diff --git a/site/src/primitives/DocumentHydrationHelper.tsx b/site/src/primitives/DocumentHydrationHelper.tsx index 49b5abda3..dd62b47ad 100644 --- a/site/src/primitives/DocumentHydrationHelper.tsx +++ b/site/src/primitives/DocumentHydrationHelper.tsx @@ -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 = {