2025-11-04 16:21:00 -08:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
useState,
|
|
|
|
|
useRef,
|
|
|
|
|
useLayoutEffect,
|
|
|
|
|
forwardRef,
|
|
|
|
|
useImperativeHandle,
|
|
|
|
|
useMemo,
|
|
|
|
|
useCallback,
|
2026-04-02 17:39:49 -07:00
|
|
|
memo,
|
2025-11-04 16:21:00 -08:00
|
|
|
} from 'react';
|
|
|
|
|
import type React from 'react';
|
|
|
|
|
import { theme } from '../../semantic-colors.js';
|
2025-11-08 16:09:22 -08:00
|
|
|
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
2025-11-04 16:21:00 -08:00
|
|
|
|
2026-04-02 17:39:49 -07:00
|
|
|
import { type DOMElement, Box, ResizeObserver, StaticRender } from 'ink';
|
2025-11-04 16:21:00 -08:00
|
|
|
|
|
|
|
|
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
|
|
|
|
|
2026-04-02 17:39:49 -07:00
|
|
|
export type VirtualizedListProps<T> = {
|
2025-11-04 16:21:00 -08:00
|
|
|
data: T[];
|
|
|
|
|
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
|
|
|
|
estimatedItemHeight: (index: number) => number;
|
|
|
|
|
keyExtractor: (item: T, index: number) => string;
|
|
|
|
|
initialScrollIndex?: number;
|
|
|
|
|
initialScrollOffsetInIndex?: number;
|
2026-04-03 15:10:04 -07:00
|
|
|
targetScrollIndex?: number;
|
|
|
|
|
backgroundColor?: string;
|
2025-11-04 16:21:00 -08:00
|
|
|
scrollbarThumbColor?: string;
|
2026-04-02 17:39:49 -07:00
|
|
|
renderStatic?: boolean;
|
|
|
|
|
isStatic?: boolean;
|
|
|
|
|
isStaticItem?: (item: T, index: number) => boolean;
|
|
|
|
|
width?: number | string;
|
|
|
|
|
overflowToBackbuffer?: boolean;
|
|
|
|
|
scrollbar?: boolean;
|
|
|
|
|
stableScrollback?: boolean;
|
|
|
|
|
copyModeEnabled?: boolean;
|
|
|
|
|
fixedItemHeight?: boolean;
|
2026-04-03 15:10:04 -07:00
|
|
|
containerHeight?: number;
|
2025-11-04 16:21:00 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type VirtualizedListRef<T> = {
|
|
|
|
|
scrollBy: (delta: number) => void;
|
|
|
|
|
scrollTo: (offset: number) => void;
|
|
|
|
|
scrollToEnd: () => void;
|
|
|
|
|
scrollToIndex: (params: {
|
|
|
|
|
index: number;
|
|
|
|
|
viewOffset?: number;
|
|
|
|
|
viewPosition?: number;
|
|
|
|
|
}) => void;
|
|
|
|
|
scrollToItem: (params: {
|
|
|
|
|
item: T;
|
|
|
|
|
viewOffset?: number;
|
|
|
|
|
viewPosition?: number;
|
|
|
|
|
}) => void;
|
|
|
|
|
getScrollIndex: () => number;
|
|
|
|
|
getScrollState: () => {
|
|
|
|
|
scrollTop: number;
|
|
|
|
|
scrollHeight: number;
|
|
|
|
|
innerHeight: number;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function findLastIndex<T>(
|
|
|
|
|
array: T[],
|
|
|
|
|
predicate: (value: T, index: number, obj: T[]) => unknown,
|
|
|
|
|
): number {
|
|
|
|
|
for (let i = array.length - 1; i >= 0; i--) {
|
2025-12-12 17:43:43 -08:00
|
|
|
if (predicate(array[i], i, array)) {
|
2025-11-04 16:21:00 -08:00
|
|
|
return i;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 17:39:49 -07:00
|
|
|
const VirtualizedListItem = memo(
|
|
|
|
|
({
|
|
|
|
|
content,
|
|
|
|
|
shouldBeStatic,
|
|
|
|
|
width,
|
|
|
|
|
containerWidth,
|
|
|
|
|
itemKey,
|
|
|
|
|
itemRef,
|
|
|
|
|
}: {
|
|
|
|
|
content: React.ReactElement;
|
|
|
|
|
shouldBeStatic: boolean;
|
|
|
|
|
width: number | string | undefined;
|
|
|
|
|
containerWidth: number;
|
|
|
|
|
itemKey: string;
|
|
|
|
|
itemRef: (el: DOMElement | null) => void;
|
|
|
|
|
}) => (
|
|
|
|
|
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
|
|
|
|
|
{shouldBeStatic ? (
|
|
|
|
|
<StaticRender
|
|
|
|
|
width={typeof width === 'number' ? width : containerWidth}
|
|
|
|
|
key={
|
|
|
|
|
itemKey +
|
|
|
|
|
'-static-' +
|
|
|
|
|
(typeof width === 'number' ? width : containerWidth)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{content}
|
|
|
|
|
</StaticRender>
|
|
|
|
|
) : (
|
|
|
|
|
content
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
VirtualizedListItem.displayName = 'VirtualizedListItem';
|
|
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
function VirtualizedList<T>(
|
|
|
|
|
props: VirtualizedListProps<T>,
|
|
|
|
|
ref: React.Ref<VirtualizedListRef<T>>,
|
|
|
|
|
) {
|
|
|
|
|
const {
|
|
|
|
|
data,
|
|
|
|
|
renderItem,
|
|
|
|
|
estimatedItemHeight,
|
|
|
|
|
keyExtractor,
|
|
|
|
|
initialScrollIndex,
|
|
|
|
|
initialScrollOffsetInIndex,
|
2026-04-02 17:39:49 -07:00
|
|
|
renderStatic,
|
|
|
|
|
isStatic,
|
|
|
|
|
isStaticItem,
|
|
|
|
|
width,
|
|
|
|
|
overflowToBackbuffer,
|
|
|
|
|
scrollbar = true,
|
|
|
|
|
stableScrollback,
|
|
|
|
|
copyModeEnabled = false,
|
|
|
|
|
fixedItemHeight = false,
|
2025-11-04 16:21:00 -08:00
|
|
|
} = props;
|
|
|
|
|
const dataRef = useRef(data);
|
2026-02-27 08:00:07 -08:00
|
|
|
useLayoutEffect(() => {
|
2025-11-04 16:21:00 -08:00
|
|
|
dataRef.current = data;
|
|
|
|
|
}, [data]);
|
|
|
|
|
|
|
|
|
|
const [scrollAnchor, setScrollAnchor] = useState(() => {
|
|
|
|
|
const scrollToEnd =
|
|
|
|
|
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
|
|
|
|
(typeof initialScrollIndex === 'number' &&
|
|
|
|
|
initialScrollIndex >= data.length - 1 &&
|
|
|
|
|
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
|
|
|
|
|
|
|
|
|
if (scrollToEnd) {
|
|
|
|
|
return {
|
|
|
|
|
index: data.length > 0 ? data.length - 1 : 0,
|
|
|
|
|
offset: SCROLL_TO_ITEM_END,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof initialScrollIndex === 'number') {
|
|
|
|
|
return {
|
|
|
|
|
index: Math.max(0, Math.min(data.length - 1, initialScrollIndex)),
|
|
|
|
|
offset: initialScrollOffsetInIndex ?? 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 15:10:04 -07:00
|
|
|
if (typeof props.targetScrollIndex === 'number') {
|
|
|
|
|
// NOTE: When targetScrollIndex is specified, we rely on the component
|
|
|
|
|
// correctly tracking targetScrollIndex instead of initialScrollIndex.
|
|
|
|
|
// We set isInitialScrollSet.current = true inside the second layout effect
|
|
|
|
|
// to avoid it overwriting the targetScrollIndex.
|
|
|
|
|
return {
|
|
|
|
|
index: props.targetScrollIndex,
|
|
|
|
|
offset: 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
return { index: 0, offset: 0 };
|
|
|
|
|
});
|
2026-02-27 08:00:07 -08:00
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
|
|
|
|
|
const scrollToEnd =
|
|
|
|
|
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
|
|
|
|
(typeof initialScrollIndex === 'number' &&
|
|
|
|
|
initialScrollIndex >= data.length - 1 &&
|
|
|
|
|
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
|
|
|
|
return scrollToEnd;
|
|
|
|
|
});
|
2026-02-27 08:00:07 -08:00
|
|
|
|
|
|
|
|
const containerRef = useRef<DOMElement | null>(null);
|
2025-11-04 16:21:00 -08:00
|
|
|
const [containerHeight, setContainerHeight] = useState(0);
|
2026-04-02 17:39:49 -07:00
|
|
|
const [containerWidth, setContainerWidth] = useState(0);
|
2025-11-04 16:21:00 -08:00
|
|
|
const itemRefs = useRef<Array<DOMElement | null>>([]);
|
2026-02-27 08:00:07 -08:00
|
|
|
const [heights, setHeights] = useState<Record<string, number>>({});
|
2025-11-04 16:21:00 -08:00
|
|
|
const isInitialScrollSet = useRef(false);
|
|
|
|
|
|
2026-02-27 08:00:07 -08:00
|
|
|
const containerObserverRef = useRef<ResizeObserver | null>(null);
|
|
|
|
|
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
|
|
|
|
|
|
|
|
|
|
const containerRefCallback = useCallback((node: DOMElement | null) => {
|
|
|
|
|
containerObserverRef.current?.disconnect();
|
|
|
|
|
containerRef.current = node;
|
|
|
|
|
if (node) {
|
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
|
|
|
const entry = entries[0];
|
|
|
|
|
if (entry) {
|
2026-04-02 17:39:49 -07:00
|
|
|
const newHeight = Math.round(entry.contentRect.height);
|
|
|
|
|
const newWidth = Math.round(entry.contentRect.width);
|
|
|
|
|
setContainerHeight((prev) => (prev !== newHeight ? newHeight : prev));
|
|
|
|
|
setContainerWidth((prev) => (prev !== newWidth ? newWidth : prev));
|
2026-02-27 08:00:07 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
observer.observe(node);
|
|
|
|
|
containerObserverRef.current = observer;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const itemsObserver = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
new ResizeObserver((entries) => {
|
|
|
|
|
setHeights((prev) => {
|
|
|
|
|
let next: Record<string, number> | null = null;
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
const key = nodeToKeyRef.current.get(entry.target);
|
|
|
|
|
if (key !== undefined) {
|
|
|
|
|
const height = Math.round(entry.contentRect.height);
|
|
|
|
|
if (prev[key] !== height) {
|
|
|
|
|
if (!next) {
|
|
|
|
|
next = { ...prev };
|
|
|
|
|
}
|
|
|
|
|
next[key] = height;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return next ?? prev;
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useLayoutEffect(
|
|
|
|
|
() => () => {
|
|
|
|
|
containerObserverRef.current?.disconnect();
|
|
|
|
|
itemsObserver.disconnect();
|
|
|
|
|
},
|
|
|
|
|
[itemsObserver],
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
const { totalHeight, offsets } = useMemo(() => {
|
|
|
|
|
const offsets: number[] = [0];
|
|
|
|
|
let totalHeight = 0;
|
|
|
|
|
for (let i = 0; i < data.length; i++) {
|
2026-02-27 08:00:07 -08:00
|
|
|
const key = keyExtractor(data[i], i);
|
|
|
|
|
const height = heights[key] ?? estimatedItemHeight(i);
|
2025-11-04 16:21:00 -08:00
|
|
|
totalHeight += height;
|
|
|
|
|
offsets.push(totalHeight);
|
|
|
|
|
}
|
|
|
|
|
return { totalHeight, offsets };
|
2026-02-27 08:00:07 -08:00
|
|
|
}, [heights, data, estimatedItemHeight, keyExtractor]);
|
2025-11-04 16:21:00 -08:00
|
|
|
|
2026-04-03 15:10:04 -07:00
|
|
|
const scrollableContainerHeight = props.containerHeight ?? containerHeight;
|
2025-11-04 16:21:00 -08:00
|
|
|
|
|
|
|
|
const getAnchorForScrollTop = useCallback(
|
|
|
|
|
(
|
|
|
|
|
scrollTop: number,
|
|
|
|
|
offsets: number[],
|
|
|
|
|
): { index: number; offset: number } => {
|
|
|
|
|
const index = findLastIndex(offsets, (offset) => offset <= scrollTop);
|
|
|
|
|
if (index === -1) {
|
|
|
|
|
return { index: 0, offset: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-12 17:43:43 -08:00
|
|
|
return { index, offset: scrollTop - offsets[index] };
|
2025-11-04 16:21:00 -08:00
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-03 15:10:04 -07:00
|
|
|
const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState(
|
|
|
|
|
props.targetScrollIndex,
|
|
|
|
|
);
|
|
|
|
|
const prevOffsetsLength = useRef(offsets.length);
|
|
|
|
|
|
|
|
|
|
// NOTE: If targetScrollIndex is provided, and we haven't rendered items yet (offsets.length <= 1),
|
|
|
|
|
// we do NOT set scrollAnchor yet, because actualScrollTop wouldn't know the real offset!
|
|
|
|
|
// We wait until offsets populate.
|
|
|
|
|
if (
|
|
|
|
|
(props.targetScrollIndex !== undefined &&
|
|
|
|
|
props.targetScrollIndex !== prevTargetScrollIndex &&
|
|
|
|
|
offsets.length > 1) ||
|
|
|
|
|
(props.targetScrollIndex !== undefined &&
|
|
|
|
|
prevOffsetsLength.current <= 1 &&
|
|
|
|
|
offsets.length > 1)
|
|
|
|
|
) {
|
|
|
|
|
if (props.targetScrollIndex !== prevTargetScrollIndex) {
|
|
|
|
|
setPrevTargetScrollIndex(props.targetScrollIndex);
|
|
|
|
|
}
|
|
|
|
|
prevOffsetsLength.current = offsets.length;
|
|
|
|
|
setIsStickingToBottom(false);
|
|
|
|
|
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
|
|
|
|
|
} else {
|
|
|
|
|
prevOffsetsLength.current = offsets.length;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 08:00:07 -08:00
|
|
|
const actualScrollTop = useMemo(() => {
|
2025-11-04 16:21:00 -08:00
|
|
|
const offset = offsets[scrollAnchor.index];
|
|
|
|
|
if (typeof offset !== 'number') {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
2026-02-27 08:00:07 -08:00
|
|
|
const item = data[scrollAnchor.index];
|
|
|
|
|
const key = item ? keyExtractor(item, scrollAnchor.index) : '';
|
|
|
|
|
const itemHeight = heights[key] ?? 0;
|
2025-11-04 16:21:00 -08:00
|
|
|
return offset + itemHeight - scrollableContainerHeight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return offset + scrollAnchor.offset;
|
2026-02-27 08:00:07 -08:00
|
|
|
}, [
|
|
|
|
|
scrollAnchor,
|
|
|
|
|
offsets,
|
|
|
|
|
heights,
|
|
|
|
|
scrollableContainerHeight,
|
|
|
|
|
data,
|
|
|
|
|
keyExtractor,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const scrollTop = isStickingToBottom
|
|
|
|
|
? Number.MAX_SAFE_INTEGER
|
|
|
|
|
: actualScrollTop;
|
2025-11-04 16:21:00 -08:00
|
|
|
|
|
|
|
|
const prevDataLength = useRef(data.length);
|
|
|
|
|
const prevTotalHeight = useRef(totalHeight);
|
2026-02-27 08:00:07 -08:00
|
|
|
const prevScrollTop = useRef(actualScrollTop);
|
2025-11-04 16:21:00 -08:00
|
|
|
const prevContainerHeight = useRef(scrollableContainerHeight);
|
|
|
|
|
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
const contentPreviouslyFit =
|
|
|
|
|
prevTotalHeight.current <= prevContainerHeight.current;
|
|
|
|
|
const wasScrolledToBottomPixels =
|
|
|
|
|
prevScrollTop.current >=
|
|
|
|
|
prevTotalHeight.current - prevContainerHeight.current - 1;
|
|
|
|
|
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
|
|
|
|
|
2026-02-27 08:00:07 -08:00
|
|
|
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
|
2026-04-02 17:39:49 -07:00
|
|
|
if (!isStickingToBottom) {
|
|
|
|
|
setIsStickingToBottom(true);
|
|
|
|
|
}
|
2025-11-04 16:21:00 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const listGrew = data.length > prevDataLength.current;
|
|
|
|
|
const containerChanged =
|
|
|
|
|
prevContainerHeight.current !== scrollableContainerHeight;
|
|
|
|
|
|
2026-04-03 15:10:04 -07:00
|
|
|
// If targetScrollIndex is provided, we NEVER auto-snap to the bottom
|
|
|
|
|
// because the parent is explicitly managing the scroll position.
|
|
|
|
|
const shouldAutoScroll = props.targetScrollIndex === undefined;
|
|
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
if (
|
2026-04-03 15:10:04 -07:00
|
|
|
shouldAutoScroll &&
|
|
|
|
|
((listGrew && (isStickingToBottom || wasAtBottom)) ||
|
|
|
|
|
(isStickingToBottom && containerChanged))
|
2025-11-04 16:21:00 -08:00
|
|
|
) {
|
2026-04-02 17:39:49 -07:00
|
|
|
const newIndex = data.length > 0 ? data.length - 1 : 0;
|
|
|
|
|
if (
|
|
|
|
|
scrollAnchor.index !== newIndex ||
|
|
|
|
|
scrollAnchor.offset !== SCROLL_TO_ITEM_END
|
|
|
|
|
) {
|
|
|
|
|
setScrollAnchor({
|
|
|
|
|
index: newIndex,
|
|
|
|
|
offset: SCROLL_TO_ITEM_END,
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-11-04 16:21:00 -08:00
|
|
|
if (!isStickingToBottom) {
|
|
|
|
|
setIsStickingToBottom(true);
|
|
|
|
|
}
|
2026-02-27 08:00:07 -08:00
|
|
|
} else if (
|
2025-11-04 16:21:00 -08:00
|
|
|
(scrollAnchor.index >= data.length ||
|
2026-02-27 08:00:07 -08:00
|
|
|
actualScrollTop > totalHeight - scrollableContainerHeight) &&
|
2025-11-04 16:21:00 -08:00
|
|
|
data.length > 0
|
|
|
|
|
) {
|
2026-04-03 15:10:04 -07:00
|
|
|
// We still clamp the scroll top if it's completely out of bounds
|
2025-11-04 16:21:00 -08:00
|
|
|
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
2026-04-02 17:39:49 -07:00
|
|
|
const newAnchor = getAnchorForScrollTop(newScrollTop, offsets);
|
|
|
|
|
if (
|
|
|
|
|
scrollAnchor.index !== newAnchor.index ||
|
|
|
|
|
scrollAnchor.offset !== newAnchor.offset
|
|
|
|
|
) {
|
|
|
|
|
setScrollAnchor(newAnchor);
|
|
|
|
|
}
|
2025-11-04 16:21:00 -08:00
|
|
|
} else if (data.length === 0) {
|
2026-04-02 17:39:49 -07:00
|
|
|
if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) {
|
|
|
|
|
setScrollAnchor({ index: 0, offset: 0 });
|
|
|
|
|
}
|
2025-11-04 16:21:00 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
prevDataLength.current = data.length;
|
|
|
|
|
prevTotalHeight.current = totalHeight;
|
2026-02-27 08:00:07 -08:00
|
|
|
prevScrollTop.current = actualScrollTop;
|
2025-11-04 16:21:00 -08:00
|
|
|
prevContainerHeight.current = scrollableContainerHeight;
|
|
|
|
|
}, [
|
|
|
|
|
data.length,
|
|
|
|
|
totalHeight,
|
2026-02-27 08:00:07 -08:00
|
|
|
actualScrollTop,
|
2025-11-04 16:21:00 -08:00
|
|
|
scrollableContainerHeight,
|
|
|
|
|
scrollAnchor.index,
|
2026-04-02 17:39:49 -07:00
|
|
|
scrollAnchor.offset,
|
2025-11-04 16:21:00 -08:00
|
|
|
getAnchorForScrollTop,
|
|
|
|
|
offsets,
|
|
|
|
|
isStickingToBottom,
|
2026-04-03 15:10:04 -07:00
|
|
|
props.targetScrollIndex,
|
2025-11-04 16:21:00 -08:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
if (
|
|
|
|
|
isInitialScrollSet.current ||
|
|
|
|
|
offsets.length <= 1 ||
|
|
|
|
|
totalHeight <= 0 ||
|
2026-04-03 15:10:04 -07:00
|
|
|
scrollableContainerHeight <= 0
|
2025-11-04 16:21:00 -08:00
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 15:10:04 -07:00
|
|
|
if (props.targetScrollIndex !== undefined) {
|
|
|
|
|
// If we are strictly driving from targetScrollIndex, do not apply initialScrollIndex
|
|
|
|
|
isInitialScrollSet.current = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
if (typeof initialScrollIndex === 'number') {
|
|
|
|
|
const scrollToEnd =
|
|
|
|
|
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
|
|
|
|
(initialScrollIndex >= data.length - 1 &&
|
|
|
|
|
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
|
|
|
|
|
|
|
|
|
if (scrollToEnd) {
|
|
|
|
|
setScrollAnchor({
|
|
|
|
|
index: data.length - 1,
|
|
|
|
|
offset: SCROLL_TO_ITEM_END,
|
|
|
|
|
});
|
|
|
|
|
setIsStickingToBottom(true);
|
|
|
|
|
isInitialScrollSet.current = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const index = Math.max(0, Math.min(data.length - 1, initialScrollIndex));
|
|
|
|
|
const offset = initialScrollOffsetInIndex ?? 0;
|
|
|
|
|
const newScrollTop = (offsets[index] ?? 0) + offset;
|
|
|
|
|
|
|
|
|
|
const clampedScrollTop = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
setScrollAnchor(getAnchorForScrollTop(clampedScrollTop, offsets));
|
|
|
|
|
isInitialScrollSet.current = true;
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
initialScrollIndex,
|
|
|
|
|
initialScrollOffsetInIndex,
|
|
|
|
|
offsets,
|
|
|
|
|
totalHeight,
|
2026-04-03 15:10:04 -07:00
|
|
|
scrollableContainerHeight,
|
2025-11-04 16:21:00 -08:00
|
|
|
getAnchorForScrollTop,
|
|
|
|
|
data.length,
|
|
|
|
|
heights,
|
2026-04-03 15:10:04 -07:00
|
|
|
props.targetScrollIndex,
|
2025-11-04 16:21:00 -08:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const startIndex = Math.max(
|
|
|
|
|
0,
|
2026-02-27 08:00:07 -08:00
|
|
|
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
|
2025-11-04 16:21:00 -08:00
|
|
|
);
|
2026-04-03 15:10:04 -07:00
|
|
|
const viewHeightForEndIndex =
|
|
|
|
|
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
|
2025-11-04 16:21:00 -08:00
|
|
|
const endIndexOffset = offsets.findIndex(
|
2026-04-03 15:10:04 -07:00
|
|
|
(offset) => offset > actualScrollTop + viewHeightForEndIndex,
|
2025-11-04 16:21:00 -08:00
|
|
|
);
|
|
|
|
|
const endIndex =
|
|
|
|
|
endIndexOffset === -1
|
|
|
|
|
? data.length - 1
|
|
|
|
|
: Math.min(data.length - 1, endIndexOffset);
|
|
|
|
|
|
2026-04-02 17:39:49 -07:00
|
|
|
const topSpacerHeight =
|
|
|
|
|
renderStatic === true || overflowToBackbuffer === true
|
|
|
|
|
? 0
|
|
|
|
|
: (offsets[startIndex] ?? 0);
|
|
|
|
|
const bottomSpacerHeight = renderStatic
|
|
|
|
|
? 0
|
|
|
|
|
: totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
2025-11-04 16:21:00 -08:00
|
|
|
|
2026-02-27 08:00:07 -08:00
|
|
|
// Maintain a stable set of observed nodes using useLayoutEffect
|
|
|
|
|
const observedNodes = useRef<Set<DOMElement>>(new Set());
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
const currentNodes = new Set<DOMElement>();
|
2026-04-02 17:39:49 -07:00
|
|
|
const observeStart = renderStatic || overflowToBackbuffer ? 0 : startIndex;
|
|
|
|
|
const observeEnd = renderStatic ? data.length - 1 : endIndex;
|
|
|
|
|
|
|
|
|
|
for (let i = observeStart; i <= observeEnd; i++) {
|
2026-02-27 08:00:07 -08:00
|
|
|
const node = itemRefs.current[i];
|
|
|
|
|
const item = data[i];
|
|
|
|
|
if (node && item) {
|
|
|
|
|
currentNodes.add(node);
|
|
|
|
|
const key = keyExtractor(item, i);
|
|
|
|
|
// Always update the key mapping because React can reuse nodes at different indices/keys
|
|
|
|
|
nodeToKeyRef.current.set(node, key);
|
2026-04-02 17:39:49 -07:00
|
|
|
if (!isStatic && !fixedItemHeight && !observedNodes.current.has(node)) {
|
2026-02-27 08:00:07 -08:00
|
|
|
itemsObserver.observe(node);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const node of observedNodes.current) {
|
|
|
|
|
if (!currentNodes.has(node)) {
|
2026-04-02 17:39:49 -07:00
|
|
|
if (!isStatic && !fixedItemHeight) {
|
|
|
|
|
itemsObserver.unobserve(node);
|
|
|
|
|
}
|
2026-02-27 08:00:07 -08:00
|
|
|
nodeToKeyRef.current.delete(node);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
observedNodes.current = currentNodes;
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
const renderedItems = [];
|
2026-04-02 17:39:49 -07:00
|
|
|
const renderRangeStart =
|
|
|
|
|
renderStatic || overflowToBackbuffer ? 0 : startIndex;
|
|
|
|
|
const renderRangeEnd = renderStatic ? data.length - 1 : endIndex;
|
|
|
|
|
|
|
|
|
|
// Always evaluate shouldBeStatic, width, etc. if we have a known width from the prop.
|
|
|
|
|
// If containerHeight or containerWidth is 0 we defer rendering unless a static render or defined width overrides.
|
|
|
|
|
// Wait, if it's not static and no width we need to wait for measure.
|
|
|
|
|
// BUT the initial render MUST render *something* with a width if width prop is provided to avoid layout shifts.
|
|
|
|
|
// We MUST wait for containerHeight > 0 before rendering, especially if renderStatic is true.
|
|
|
|
|
// If containerHeight is 0, we will misclassify items as isOutsideViewport and permanently print them to StaticRender!
|
|
|
|
|
const isReady =
|
|
|
|
|
containerHeight > 0 ||
|
|
|
|
|
process.env['NODE_ENV'] === 'test' ||
|
|
|
|
|
(width !== undefined && typeof width === 'number');
|
|
|
|
|
|
|
|
|
|
if (isReady) {
|
|
|
|
|
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
|
|
|
|
|
const item = data[i];
|
|
|
|
|
if (item) {
|
|
|
|
|
const isOutsideViewport = i < startIndex || i > endIndex;
|
|
|
|
|
const shouldBeStatic =
|
|
|
|
|
(renderStatic === true && isOutsideViewport) ||
|
|
|
|
|
isStaticItem?.(item, i) === true;
|
|
|
|
|
|
|
|
|
|
const content = renderItem({ item, index: i });
|
|
|
|
|
const key = keyExtractor(item, i);
|
|
|
|
|
|
|
|
|
|
renderedItems.push(
|
|
|
|
|
<VirtualizedListItem
|
|
|
|
|
key={key}
|
|
|
|
|
itemKey={key}
|
|
|
|
|
content={content}
|
|
|
|
|
shouldBeStatic={shouldBeStatic}
|
|
|
|
|
width={width}
|
|
|
|
|
containerWidth={containerWidth}
|
|
|
|
|
itemRef={(el: DOMElement | null) => {
|
|
|
|
|
if (i >= renderRangeStart && i <= renderRangeEnd) {
|
|
|
|
|
itemRefs.current[i] = el;
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-11-04 16:21:00 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-08 16:09:22 -08:00
|
|
|
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
|
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
useImperativeHandle(
|
|
|
|
|
ref,
|
|
|
|
|
() => ({
|
|
|
|
|
scrollBy: (delta: number) => {
|
|
|
|
|
if (delta < 0) {
|
|
|
|
|
setIsStickingToBottom(false);
|
|
|
|
|
}
|
2025-11-08 16:09:22 -08:00
|
|
|
const currentScrollTop = getScrollTop();
|
2026-02-27 08:00:07 -08:00
|
|
|
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
|
|
|
|
const actualCurrent = Math.min(currentScrollTop, maxScroll);
|
|
|
|
|
let newScrollTop = Math.max(0, actualCurrent + delta);
|
|
|
|
|
if (newScrollTop >= maxScroll) {
|
|
|
|
|
setIsStickingToBottom(true);
|
|
|
|
|
newScrollTop = Number.MAX_SAFE_INTEGER;
|
|
|
|
|
}
|
2025-11-08 16:09:22 -08:00
|
|
|
setPendingScrollTop(newScrollTop);
|
2026-02-27 08:00:07 -08:00
|
|
|
setScrollAnchor(
|
|
|
|
|
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets),
|
|
|
|
|
);
|
2025-11-04 16:21:00 -08:00
|
|
|
},
|
|
|
|
|
scrollTo: (offset: number) => {
|
2026-02-27 08:00:07 -08:00
|
|
|
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
|
|
|
|
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
|
|
|
|
|
setIsStickingToBottom(true);
|
|
|
|
|
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
|
|
|
|
if (data.length > 0) {
|
|
|
|
|
setScrollAnchor({
|
|
|
|
|
index: data.length - 1,
|
|
|
|
|
offset: SCROLL_TO_ITEM_END,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
setIsStickingToBottom(false);
|
|
|
|
|
const newScrollTop = Math.max(0, offset);
|
|
|
|
|
setPendingScrollTop(newScrollTop);
|
|
|
|
|
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
|
|
|
|
}
|
2025-11-04 16:21:00 -08:00
|
|
|
},
|
|
|
|
|
scrollToEnd: () => {
|
|
|
|
|
setIsStickingToBottom(true);
|
2026-02-27 08:00:07 -08:00
|
|
|
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
2025-11-04 16:21:00 -08:00
|
|
|
if (data.length > 0) {
|
|
|
|
|
setScrollAnchor({
|
|
|
|
|
index: data.length - 1,
|
|
|
|
|
offset: SCROLL_TO_ITEM_END,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
scrollToIndex: ({
|
|
|
|
|
index,
|
|
|
|
|
viewOffset = 0,
|
|
|
|
|
viewPosition = 0,
|
|
|
|
|
}: {
|
|
|
|
|
index: number;
|
|
|
|
|
viewOffset?: number;
|
|
|
|
|
viewPosition?: number;
|
|
|
|
|
}) => {
|
|
|
|
|
setIsStickingToBottom(false);
|
|
|
|
|
const offset = offsets[index];
|
|
|
|
|
if (offset !== undefined) {
|
2026-02-27 08:00:07 -08:00
|
|
|
const maxScroll = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
totalHeight - scrollableContainerHeight,
|
|
|
|
|
);
|
2025-11-04 16:21:00 -08:00
|
|
|
const newScrollTop = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
Math.min(
|
2026-02-27 08:00:07 -08:00
|
|
|
maxScroll,
|
2025-11-04 16:21:00 -08:00
|
|
|
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
|
|
|
|
),
|
|
|
|
|
);
|
2025-11-08 16:09:22 -08:00
|
|
|
setPendingScrollTop(newScrollTop);
|
2025-11-04 16:21:00 -08:00
|
|
|
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
scrollToItem: ({
|
|
|
|
|
item,
|
|
|
|
|
viewOffset = 0,
|
|
|
|
|
viewPosition = 0,
|
|
|
|
|
}: {
|
|
|
|
|
item: T;
|
|
|
|
|
viewOffset?: number;
|
|
|
|
|
viewPosition?: number;
|
|
|
|
|
}) => {
|
|
|
|
|
setIsStickingToBottom(false);
|
|
|
|
|
const index = data.indexOf(item);
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
const offset = offsets[index];
|
|
|
|
|
if (offset !== undefined) {
|
2026-02-27 08:00:07 -08:00
|
|
|
const maxScroll = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
totalHeight - scrollableContainerHeight,
|
|
|
|
|
);
|
2025-11-04 16:21:00 -08:00
|
|
|
const newScrollTop = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
Math.min(
|
2026-02-27 08:00:07 -08:00
|
|
|
maxScroll,
|
2025-11-04 16:21:00 -08:00
|
|
|
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
|
|
|
|
),
|
|
|
|
|
);
|
2025-11-08 16:09:22 -08:00
|
|
|
setPendingScrollTop(newScrollTop);
|
2025-11-04 16:21:00 -08:00
|
|
|
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
getScrollIndex: () => scrollAnchor.index,
|
2026-02-27 08:00:07 -08:00
|
|
|
getScrollState: () => {
|
2026-04-03 15:10:04 -07:00
|
|
|
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
2026-02-27 08:00:07 -08:00
|
|
|
return {
|
|
|
|
|
scrollTop: Math.min(getScrollTop(), maxScroll),
|
|
|
|
|
scrollHeight: totalHeight,
|
2026-04-03 15:10:04 -07:00
|
|
|
innerHeight: scrollableContainerHeight,
|
2026-02-27 08:00:07 -08:00
|
|
|
};
|
|
|
|
|
},
|
2025-11-04 16:21:00 -08:00
|
|
|
}),
|
|
|
|
|
[
|
|
|
|
|
offsets,
|
|
|
|
|
scrollAnchor,
|
|
|
|
|
totalHeight,
|
|
|
|
|
getAnchorForScrollTop,
|
|
|
|
|
data,
|
|
|
|
|
scrollableContainerHeight,
|
2025-11-08 16:09:22 -08:00
|
|
|
getScrollTop,
|
|
|
|
|
setPendingScrollTop,
|
2025-11-04 16:21:00 -08:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box
|
2026-02-27 08:00:07 -08:00
|
|
|
ref={containerRefCallback}
|
2026-02-11 07:30:27 +11:00
|
|
|
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
|
2025-11-04 16:21:00 -08:00
|
|
|
overflowX="hidden"
|
2026-02-11 07:30:27 +11:00
|
|
|
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
2025-11-04 16:21:00 -08:00
|
|
|
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
2026-04-03 15:10:04 -07:00
|
|
|
backgroundColor={props.backgroundColor}
|
2025-11-04 16:21:00 -08:00
|
|
|
width="100%"
|
|
|
|
|
height="100%"
|
|
|
|
|
flexDirection="column"
|
2026-02-11 07:30:27 +11:00
|
|
|
paddingRight={copyModeEnabled ? 0 : 1}
|
2026-04-02 17:39:49 -07:00
|
|
|
overflowToBackbuffer={overflowToBackbuffer}
|
|
|
|
|
scrollbar={scrollbar}
|
|
|
|
|
stableScrollback={stableScrollback}
|
2025-11-04 16:21:00 -08:00
|
|
|
>
|
2026-02-11 07:30:27 +11:00
|
|
|
<Box
|
|
|
|
|
flexShrink={0}
|
|
|
|
|
width="100%"
|
|
|
|
|
flexDirection="column"
|
2026-02-27 08:00:07 -08:00
|
|
|
marginTop={copyModeEnabled ? -actualScrollTop : 0}
|
2026-02-11 07:30:27 +11:00
|
|
|
>
|
2025-11-04 16:21:00 -08:00
|
|
|
<Box height={topSpacerHeight} flexShrink={0} />
|
|
|
|
|
{renderedItems}
|
|
|
|
|
<Box height={bottomSpacerHeight} flexShrink={0} />
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 00:10:15 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
2025-11-04 16:21:00 -08:00
|
|
|
const VirtualizedListWithForwardRef = forwardRef(VirtualizedList) as <T>(
|
|
|
|
|
props: VirtualizedListProps<T> & { ref?: React.Ref<VirtualizedListRef<T>> },
|
|
|
|
|
) => React.ReactElement;
|
|
|
|
|
|
|
|
|
|
export { VirtualizedListWithForwardRef as VirtualizedList };
|
|
|
|
|
|
|
|
|
|
VirtualizedList.displayName = 'VirtualizedList';
|