From 41547e70b134eef4e1ddab9897aee368f310718f Mon Sep 17 00:00:00 2001 From: jaydevelopsstuff <70743392+jaydevelopsstuff@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:01:04 -0700 Subject: [PATCH 1/5] First shot at minimal dynamic item size support for virtual-list --- packages/virtual/dev/index.tsx | 25 +++++-- packages/virtual/src/index.tsx | 68 ++++++++++++++++--- .../primitives/DocumentHydrationHelper.tsx | 2 +- 3 files changed, 78 insertions(+), 17 deletions(-) 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..339516e9c 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,7 @@ type VirtualListReturn = [ visibleItems: T; }>, onScroll: (e: Event) => void, + { scrollToItem: (itemIndex: number, scrollContainer: HTMLElement) => void }, ]; /** @@ -39,26 +40,65 @@ export function createVirtualList({ rowHeight = access(rowHeight); overscanCount = access(overscanCount) || 1; + const resolveRowHeight = + typeof rowHeight === "function" ? rowHeight : (_: T[number], _i: number) => rowHeight; + 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; + offset += resolveRowHeight(item, i); + return current; + }); + }); + + 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, - ); + Math.min(items.length, findRowIndexAtOffset(offset() + 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, + 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); }, + { + scrollToItem: (itemIndex: number, scrollContainer: HTMLElement) => { + scrollContainer.scrollTop = rowOffsets()[itemIndex]!; + }, + }, ]; } @@ -67,8 +107,9 @@ type VirtualListProps = { 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; }; /** @@ -85,15 +126,20 @@ type VirtualListProps = { export function VirtualList( props: VirtualListProps, ): JSX.Element { - const [virtual, onScroll] = createVirtualList({ + const [virtual, onScroll, { scrollToItem }] = 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 (
Date: Mon, 30 Jun 2025 18:23:18 -0700 Subject: [PATCH 2/5] Add note to doc about rowHeight's dynamic capability --- packages/virtual/src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index 339516e9c..3dd0ed7d4 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -120,7 +120,7 @@ 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( From a129664811460982a2e84083bae1d40b731e235e Mon Sep 17 00:00:00 2001 From: jaydevelopsstuff <70743392+jaydevelopsstuff@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:40:06 -0700 Subject: [PATCH 3/5] Minor comment on use of binary search --- packages/virtual/src/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index 3dd0ed7d4..d8dd476e3 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -54,6 +54,7 @@ export function createVirtualList({ }); }); + // Binary Search for performance const findRowIndexAtOffset = (offset: number) => { const offsets = rowOffsets(); From 9dc934b741dfc000113807a00b977815bf3a9313 Mon Sep 17 00:00:00 2001 From: jaydevelopsstuff <70743392+jaydevelopsstuff@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:03:08 -0700 Subject: [PATCH 4/5] Fix reactivity in createVirtualList --- packages/virtual/src/index.tsx | 35 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index d8dd476e3..8f29b26f6 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -29,27 +29,21 @@ 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; - - const resolveRowHeight = - typeof rowHeight === "function" ? rowHeight : (_: T[number], _i: number) => rowHeight; +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 rowOffsets = createMemo(() => { let offset = 0; - return items.map((item, i) => { + return items().map((item, i) => { const current = offset; - offset += resolveRowHeight(item, i); + const rowHeight = access(cfg.rowHeight); + + offset += typeof rowHeight === "function" ? rowHeight(item, i) : rowHeight; return current; }); }); @@ -72,12 +66,15 @@ export function createVirtualList({ return lo; }; - const getFirstIdx = () => Math.max(0, findRowIndexAtOffset(offset()) - overscanCount); + const getFirstIdx = () => Math.max(0, findRowIndexAtOffset(offset()) - overscanCount()); // const getFirstIdx = () => Math.max(0, Math.floor(offset() / rowHeight) - overscanCount); const getLastIdx = () => - Math.min(items.length, findRowIndexAtOffset(offset() + rootHeight) + overscanCount); + Math.min( + items().length, + findRowIndexAtOffset(offset() + access(cfg.rootHeight)) + overscanCount(), + ); // const getLastIdx = () => // Math.min( @@ -87,9 +84,9 @@ export function createVirtualList({ return [ () => ({ - containerHeight: items.length !== 0 ? rowOffsets()[items.length - 1]! : 0, + containerHeight: items().length !== 0 ? rowOffsets()[items().length - 1]! : 0, viewerTop: rowOffsets()[getFirstIdx()]!, - visibleItems: items.slice(getFirstIdx(), getLastIdx()) as unknown as T, + visibleItems: items().slice(getFirstIdx(), getLastIdx()) as unknown as T, }), e => { // @ts-expect-error From c09050d7f9527ac49c2342fe79ae1850ca707d32 Mon Sep 17 00:00:00 2001 From: jaydevelopsstuff <70743392+jaydevelopsstuff@users.noreply.github.com> Date: Thu, 3 Jul 2025 02:52:11 -0700 Subject: [PATCH 5/5] Ensure parent width is retained within actual scroll viewer and make callback indexes more useful --- packages/virtual/src/index.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index 8f29b26f6..b3b573e3b 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -17,7 +17,11 @@ type VirtualListReturn = [ visibleItems: T; }>, onScroll: (e: Event) => void, - { scrollToItem: (itemIndex: number, scrollContainer: HTMLElement) => void }, + { + getFirstIdx: () => number; + getLastIdx: () => number; + scrollToItem: (itemIndex: number, scrollContainer: HTMLElement) => void; + }, ]; /** @@ -93,6 +97,8 @@ export function createVirtualList( if (e.target?.scrollTop !== undefined) setOffset(e.target.scrollTop); }, { + getFirstIdx, + getLastIdx, scrollToItem: (itemIndex: number, scrollContainer: HTMLElement) => { scrollContainer.scrollTop = rowOffsets()[itemIndex]!; }, @@ -101,7 +107,7 @@ export function createVirtualList( } 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; @@ -124,7 +130,7 @@ type VirtualListProps = { export function VirtualList( props: VirtualListProps, ): JSX.Element { - const [virtual, onScroll, { scrollToItem }] = createVirtualList({ + const [virtual, onScroll, { scrollToItem, getFirstIdx }] = createVirtualList({ items: () => props.each, rootHeight: () => props.rootHeight, rowHeight: () => props.rowHeight, @@ -155,10 +161,11 @@ export function VirtualList( style={{ position: "absolute", top: `${virtual().viewerTop}px`, + width: "inherit", }} > - {props.children} + {(item, index) => props.children(item, () => getFirstIdx() + index(), index)}