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,
|
2026-04-06 21:30:30 -07:00
|
|
|
useEffect,
|
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;
|
|
|
|
|
fixedItemHeight?: boolean;
|
2026-04-03 15:10:04 -07:00
|
|
|
containerHeight?: number;
|
2026-04-06 21:30:30 -07:00
|
|
|
maxScrollbackLength?: 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-06 21:30:30 -07:00
|
|
|
const MemoizedStaticItem = memo(
|
2026-04-02 17:39:49 -07:00
|
|
|
({
|
|
|
|
|
content,
|
|
|
|
|
width,
|
2026-04-06 21:30:30 -07:00
|
|
|
itemKey,
|
|
|
|
|
}: {
|
|
|
|
|
content: React.ReactElement;
|
|
|
|
|
width: number;
|
|
|
|
|
itemKey: string;
|
|
|
|
|
}) => (
|
|
|
|
|
<StaticRender width={width} key={itemKey}>
|
|
|
|
|
{() => content}
|
|
|
|
|
</StaticRender>
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
MemoizedStaticItem.displayName = 'MemoizedStaticItem';
|
|
|
|
|
|
|
|
|
|
const VirtualizedListItem = memo(
|
|
|
|
|
({
|
|
|
|
|
content,
|
2026-04-02 17:39:49 -07:00
|
|
|
itemKey,
|
2026-04-06 10:20:38 -07:00
|
|
|
index,
|
|
|
|
|
onSetRef,
|
2026-04-02 17:39:49 -07:00
|
|
|
}: {
|
|
|
|
|
content: React.ReactElement;
|
|
|
|
|
itemKey: string;
|
2026-04-06 10:20:38 -07:00
|
|
|
index: number;
|
2026-04-06 21:30:30 -07:00
|
|
|
onSetRef: (index: number, itemKey: string, el: DOMElement | null) => void;
|
2026-04-06 10:20:38 -07:00
|
|
|
}) => {
|
|
|
|
|
const itemRef = useCallback(
|
|
|
|
|
(el: DOMElement | null) => {
|
2026-04-06 21:30:30 -07:00
|
|
|
onSetRef(index, itemKey, el);
|
2026-04-06 10:20:38 -07:00
|
|
|
},
|
2026-04-06 21:30:30 -07:00
|
|
|
[index, itemKey, onSetRef],
|
2026-04-06 10:20:38 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
|
2026-04-06 21:30:30 -07:00
|
|
|
{content}
|
2026-04-06 10:20:38 -07:00
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-04-02 17:39:49 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
VirtualizedListItem.displayName = 'VirtualizedListItem';
|
|
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
interface VirtualizedListInternalState {
|
|
|
|
|
container: DOMElement | null;
|
|
|
|
|
itemRefs: Array<DOMElement | null>;
|
|
|
|
|
measuredHeights: number[];
|
|
|
|
|
measuredKeys: string[];
|
|
|
|
|
isInitialScrollSet: boolean;
|
|
|
|
|
containerObserver: ResizeObserver | null;
|
|
|
|
|
prevOffsetsLength: number;
|
|
|
|
|
prevDataLength: number;
|
|
|
|
|
prevTotalHeight: number;
|
|
|
|
|
prevScrollTop: number;
|
|
|
|
|
prevContainerHeight: number;
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-04-06 21:30:30 -07:00
|
|
|
maxScrollbackLength,
|
2025-11-04 16:21:00 -08:00
|
|
|
} = props;
|
|
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
const [scrollAnchor, setScrollAnchor] = useState<{
|
|
|
|
|
index: number;
|
|
|
|
|
offset: number;
|
|
|
|
|
isBottom?: boolean;
|
|
|
|
|
}>(() => {
|
2025-11-04 16:21:00 -08:00
|
|
|
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
|
|
|
|
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);
|
2026-04-06 21:30:30 -07:00
|
|
|
const [measurementVersion, setMeasurementVersion] = useState(0);
|
|
|
|
|
|
|
|
|
|
const state = useRef<VirtualizedListInternalState>({
|
|
|
|
|
container: null,
|
|
|
|
|
itemRefs: [],
|
|
|
|
|
measuredHeights: [],
|
|
|
|
|
measuredKeys: [],
|
|
|
|
|
isInitialScrollSet: false,
|
|
|
|
|
containerObserver: null,
|
|
|
|
|
prevOffsetsLength: -1,
|
|
|
|
|
prevDataLength: -1,
|
|
|
|
|
prevTotalHeight: -1,
|
|
|
|
|
prevScrollTop: -1,
|
|
|
|
|
prevContainerHeight: -1,
|
|
|
|
|
});
|
2025-11-04 16:21:00 -08:00
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
const itemsObserver = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
new ResizeObserver((entries) => {
|
|
|
|
|
let changed = false;
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
// Extract index and key safely from the element
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
|
|
|
|
const target = entry.target as any;
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
|
|
|
const index = target._virtualIndex;
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
|
|
|
const key = target._virtualKey;
|
|
|
|
|
if (typeof index === 'number' && key !== undefined) {
|
|
|
|
|
const height = Math.round(entry.contentRect.height);
|
|
|
|
|
// Ignore 0 height measurements which can happen when an element is unmounting
|
|
|
|
|
if (
|
|
|
|
|
height > 0 &&
|
|
|
|
|
(state.current.measuredHeights[index] !== height ||
|
|
|
|
|
state.current.measuredKeys[index] !== key)
|
|
|
|
|
) {
|
|
|
|
|
state.current.measuredHeights[index] = height;
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
|
|
|
state.current.measuredKeys[index] = key;
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (changed) {
|
|
|
|
|
setMeasurementVersion((v) => v + 1);
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
[],
|
|
|
|
|
);
|
2026-02-27 08:00:07 -08:00
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
const onSetRef = useCallback(
|
|
|
|
|
(index: number, itemKey: string, el: DOMElement | null) => {
|
|
|
|
|
const oldEl = state.current.itemRefs[index];
|
|
|
|
|
if (oldEl && oldEl !== el) {
|
|
|
|
|
if (!isStatic) {
|
|
|
|
|
itemsObserver.unobserve(oldEl);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.current.itemRefs[index] = el;
|
|
|
|
|
|
|
|
|
|
if (el) {
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
|
|
|
|
const target = el as any;
|
|
|
|
|
|
|
|
|
|
target._virtualIndex = index;
|
|
|
|
|
|
|
|
|
|
target._virtualKey = itemKey;
|
|
|
|
|
if (!isStatic) {
|
|
|
|
|
itemsObserver.observe(el);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[itemsObserver, isStatic],
|
|
|
|
|
);
|
2026-04-06 10:20:38 -07:00
|
|
|
|
2026-02-27 08:00:07 -08:00
|
|
|
const containerRefCallback = useCallback((node: DOMElement | null) => {
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.containerObserver?.disconnect();
|
|
|
|
|
state.current.container = node;
|
2026-02-27 08:00:07 -08:00
|
|
|
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);
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.containerObserver = observer;
|
2026-02-27 08:00:07 -08:00
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
useEffect(
|
2026-02-27 08:00:07 -08:00
|
|
|
() => () => {
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.containerObserver?.disconnect();
|
2026-02-27 08:00:07 -08:00
|
|
|
itemsObserver.disconnect();
|
|
|
|
|
},
|
|
|
|
|
[itemsObserver],
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
const { totalHeight, offsets } = (() => {
|
2025-11-04 16:21:00 -08:00
|
|
|
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);
|
2026-04-06 21:30:30 -07:00
|
|
|
const cachedHeight =
|
|
|
|
|
state.current.measuredKeys[i] === key
|
|
|
|
|
? state.current.measuredHeights[i]
|
|
|
|
|
: undefined;
|
|
|
|
|
const height = cachedHeight ?? estimatedItemHeight(i);
|
2025-11-04 16:21:00 -08:00
|
|
|
totalHeight += height;
|
|
|
|
|
offsets.push(totalHeight);
|
|
|
|
|
}
|
|
|
|
|
return { totalHeight, offsets };
|
2026-04-06 21:30:30 -07:00
|
|
|
})();
|
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[],
|
2026-04-06 21:30:30 -07:00
|
|
|
totalHeight: number,
|
|
|
|
|
scrollableContainerHeight: number,
|
|
|
|
|
): { index: number; offset: number; isBottom?: boolean } => {
|
|
|
|
|
const isNearBottom =
|
|
|
|
|
totalHeight > 0 &&
|
|
|
|
|
scrollTop > (totalHeight - scrollableContainerHeight) / 2;
|
|
|
|
|
|
|
|
|
|
if (isNearBottom) {
|
|
|
|
|
const scrollBottom = scrollTop + scrollableContainerHeight;
|
|
|
|
|
const index = findLastIndex(
|
|
|
|
|
offsets,
|
|
|
|
|
(offset) => offset <= scrollBottom,
|
|
|
|
|
);
|
|
|
|
|
if (index === -1) {
|
|
|
|
|
return { index: 0, offset: 0, isBottom: true };
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
index,
|
|
|
|
|
offset: scrollBottom - offsets[index],
|
|
|
|
|
isBottom: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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 &&
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.prevOffsetsLength <= 1 &&
|
2026-04-03 15:10:04 -07:00
|
|
|
offsets.length > 1)
|
|
|
|
|
) {
|
|
|
|
|
if (props.targetScrollIndex !== prevTargetScrollIndex) {
|
|
|
|
|
setPrevTargetScrollIndex(props.targetScrollIndex);
|
|
|
|
|
}
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.prevOffsetsLength = offsets.length;
|
2026-04-03 15:10:04 -07:00
|
|
|
setIsStickingToBottom(false);
|
|
|
|
|
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
|
2026-04-06 21:30:30 -07:00
|
|
|
} else if (offsets.length > 1) {
|
|
|
|
|
state.current.prevOffsetsLength = offsets.length;
|
2026-04-03 15:10:04 -07:00
|
|
|
}
|
|
|
|
|
|
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) : '';
|
2026-04-06 21:30:30 -07:00
|
|
|
const cachedHeight =
|
|
|
|
|
state.current.measuredKeys[scrollAnchor.index] === key
|
|
|
|
|
? state.current.measuredHeights[scrollAnchor.index]
|
|
|
|
|
: undefined;
|
|
|
|
|
const itemHeight =
|
|
|
|
|
cachedHeight ?? estimatedItemHeight(scrollAnchor.index) ?? 0;
|
2025-11-04 16:21:00 -08:00
|
|
|
return offset + itemHeight - scrollableContainerHeight;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
if (scrollAnchor.isBottom) {
|
|
|
|
|
return offset + scrollAnchor.offset - scrollableContainerHeight;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
return offset + scrollAnchor.offset;
|
2026-02-27 08:00:07 -08:00
|
|
|
}, [
|
|
|
|
|
scrollAnchor,
|
|
|
|
|
offsets,
|
|
|
|
|
scrollableContainerHeight,
|
|
|
|
|
data,
|
|
|
|
|
keyExtractor,
|
2026-04-06 21:30:30 -07:00
|
|
|
estimatedItemHeight,
|
2026-02-27 08:00:07 -08:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const scrollTop = isStickingToBottom
|
|
|
|
|
? Number.MAX_SAFE_INTEGER
|
|
|
|
|
: actualScrollTop;
|
2025-11-04 16:21:00 -08:00
|
|
|
|
|
|
|
|
useLayoutEffect(() => {
|
2026-04-06 21:30:30 -07:00
|
|
|
if (state.current.prevDataLength === -1) {
|
|
|
|
|
state.current.prevDataLength = data.length;
|
|
|
|
|
state.current.prevTotalHeight = totalHeight;
|
|
|
|
|
state.current.prevScrollTop = actualScrollTop;
|
|
|
|
|
state.current.prevContainerHeight = scrollableContainerHeight;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 16:21:00 -08:00
|
|
|
const contentPreviouslyFit =
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.prevTotalHeight <= state.current.prevContainerHeight;
|
2025-11-04 16:21:00 -08:00
|
|
|
const wasScrolledToBottomPixels =
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.prevScrollTop >=
|
|
|
|
|
state.current.prevTotalHeight - state.current.prevContainerHeight - 1;
|
2025-11-04 16:21:00 -08:00
|
|
|
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
|
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
if (wasAtBottom && actualScrollTop >= state.current.prevScrollTop) {
|
2026-04-02 17:39:49 -07:00
|
|
|
if (!isStickingToBottom) {
|
|
|
|
|
setIsStickingToBottom(true);
|
|
|
|
|
}
|
2025-11-04 16:21:00 -08:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
const listGrew = data.length > state.current.prevDataLength;
|
2025-11-04 16:21:00 -08:00
|
|
|
const containerChanged =
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.prevContainerHeight !== scrollableContainerHeight;
|
2025-11-04 16:21:00 -08:00
|
|
|
|
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-06 21:30:30 -07:00
|
|
|
const newAnchor = getAnchorForScrollTop(
|
|
|
|
|
newScrollTop,
|
|
|
|
|
offsets,
|
|
|
|
|
totalHeight,
|
|
|
|
|
scrollableContainerHeight,
|
|
|
|
|
);
|
2026-04-02 17:39:49 -07:00
|
|
|
if (
|
|
|
|
|
scrollAnchor.index !== newAnchor.index ||
|
2026-04-06 21:30:30 -07:00
|
|
|
scrollAnchor.offset !== newAnchor.offset ||
|
|
|
|
|
scrollAnchor.isBottom !== newAnchor.isBottom
|
2026-04-02 17:39:49 -07:00
|
|
|
) {
|
|
|
|
|
setScrollAnchor(newAnchor);
|
|
|
|
|
}
|
2025-11-04 16:21:00 -08:00
|
|
|
} else if (data.length === 0) {
|
2026-04-06 21:30:30 -07:00
|
|
|
if (
|
|
|
|
|
scrollAnchor.index !== 0 ||
|
|
|
|
|
scrollAnchor.offset !== 0 ||
|
|
|
|
|
scrollAnchor.isBottom !== undefined
|
|
|
|
|
) {
|
2026-04-02 17:39:49 -07:00
|
|
|
setScrollAnchor({ index: 0, offset: 0 });
|
|
|
|
|
}
|
2025-11-04 16:21:00 -08:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.prevDataLength = data.length;
|
|
|
|
|
state.current.prevTotalHeight = totalHeight;
|
|
|
|
|
state.current.prevScrollTop = actualScrollTop;
|
|
|
|
|
state.current.prevContainerHeight = scrollableContainerHeight;
|
2025-11-04 16:21:00 -08:00
|
|
|
}, [
|
|
|
|
|
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,
|
2026-04-06 21:30:30 -07:00
|
|
|
scrollAnchor.isBottom,
|
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 (
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.isInitialScrollSet ||
|
2025-11-04 16:21:00 -08:00
|
|
|
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
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.isInitialScrollSet = true;
|
2026-04-03 15:10:04 -07:00
|
|
|
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);
|
2026-04-06 21:30:30 -07:00
|
|
|
state.current.isInitialScrollSet = true;
|
2025-11-04 16:21:00 -08:00
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
setScrollAnchor(
|
|
|
|
|
getAnchorForScrollTop(
|
|
|
|
|
clampedScrollTop,
|
|
|
|
|
offsets,
|
|
|
|
|
totalHeight,
|
|
|
|
|
scrollableContainerHeight,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
state.current.isInitialScrollSet = true;
|
2025-11-04 16:21:00 -08:00
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
initialScrollIndex,
|
|
|
|
|
initialScrollOffsetInIndex,
|
|
|
|
|
offsets,
|
|
|
|
|
totalHeight,
|
2026-04-03 15:10:04 -07:00
|
|
|
scrollableContainerHeight,
|
2025-11-04 16:21:00 -08:00
|
|
|
getAnchorForScrollTop,
|
|
|
|
|
data.length,
|
2026-04-06 21:30:30 -07:00
|
|
|
measurementVersion,
|
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-06 21:30:30 -07:00
|
|
|
const renderRangeStart = useMemo(() => {
|
|
|
|
|
if (overflowToBackbuffer) {
|
|
|
|
|
if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) {
|
|
|
|
|
const targetOffset = Math.max(0, actualScrollTop - maxScrollbackLength);
|
|
|
|
|
const index = findLastIndex(
|
|
|
|
|
offsets,
|
|
|
|
|
(offset) => offset <= targetOffset,
|
|
|
|
|
);
|
|
|
|
|
return Math.max(0, index - 1);
|
2026-02-27 08:00:07 -08:00
|
|
|
}
|
2026-04-06 21:30:30 -07:00
|
|
|
return 0;
|
2026-02-27 08:00:07 -08:00
|
|
|
}
|
2026-04-06 21:30:30 -07:00
|
|
|
return startIndex;
|
|
|
|
|
}, [
|
|
|
|
|
overflowToBackbuffer,
|
|
|
|
|
maxScrollbackLength,
|
|
|
|
|
actualScrollTop,
|
|
|
|
|
offsets,
|
|
|
|
|
startIndex,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const topSpacerHeight = offsets[renderRangeStart];
|
|
|
|
|
const bottomSpacerHeight =
|
|
|
|
|
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
2026-02-27 08:00:07 -08:00
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
const renderRangeEnd = endIndex;
|
2026-04-02 17:39:49 -07:00
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
2026-04-06 10:20:38 -07:00
|
|
|
const renderedItems = useMemo(() => {
|
|
|
|
|
if (!isReady) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const items = [];
|
2026-04-02 17:39:49 -07:00
|
|
|
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);
|
|
|
|
|
|
2026-04-06 21:30:30 -07:00
|
|
|
if (shouldBeStatic) {
|
|
|
|
|
items.push(
|
|
|
|
|
<MemoizedStaticItem
|
|
|
|
|
key={`${key}-static`}
|
|
|
|
|
itemKey={`${key}-static-${typeof width === 'number' ? width : containerWidth}`}
|
|
|
|
|
width={typeof width === 'number' ? width : containerWidth}
|
|
|
|
|
content={content}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
items.push(
|
|
|
|
|
<VirtualizedListItem
|
|
|
|
|
key={key}
|
|
|
|
|
itemKey={key}
|
|
|
|
|
content={content}
|
|
|
|
|
index={i}
|
|
|
|
|
onSetRef={onSetRef}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!renderStatic &&
|
|
|
|
|
state.current.measuredKeys[i] !== key &&
|
|
|
|
|
!shouldBeStatic
|
|
|
|
|
) {
|
|
|
|
|
const fillerHeight = Math.max(0, estimatedItemHeight(i) - 1);
|
|
|
|
|
if (fillerHeight > 0) {
|
|
|
|
|
items.push(
|
|
|
|
|
<Box
|
|
|
|
|
key={key + '-filler'}
|
|
|
|
|
height={fillerHeight}
|
|
|
|
|
flexShrink={0}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 17:39:49 -07:00
|
|
|
}
|
2025-11-04 16:21:00 -08:00
|
|
|
}
|
2026-04-06 10:20:38 -07:00
|
|
|
return items;
|
|
|
|
|
}, [
|
|
|
|
|
isReady,
|
|
|
|
|
renderRangeStart,
|
|
|
|
|
renderRangeEnd,
|
|
|
|
|
data,
|
|
|
|
|
startIndex,
|
|
|
|
|
endIndex,
|
|
|
|
|
renderStatic,
|
|
|
|
|
isStaticItem,
|
|
|
|
|
renderItem,
|
|
|
|
|
keyExtractor,
|
|
|
|
|
width,
|
|
|
|
|
containerWidth,
|
|
|
|
|
onSetRef,
|
2026-04-06 21:30:30 -07:00
|
|
|
estimatedItemHeight,
|
2026-04-06 10:20:38 -07:00
|
|
|
]);
|
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(
|
2026-04-06 21:30:30 -07:00
|
|
|
getAnchorForScrollTop(
|
|
|
|
|
Math.min(newScrollTop, maxScroll),
|
|
|
|
|
offsets,
|
|
|
|
|
totalHeight,
|
|
|
|
|
scrollableContainerHeight,
|
|
|
|
|
),
|
2026-02-27 08:00:07 -08:00
|
|
|
);
|
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);
|
2026-04-06 21:30:30 -07:00
|
|
|
setScrollAnchor(
|
|
|
|
|
getAnchorForScrollTop(
|
|
|
|
|
newScrollTop,
|
|
|
|
|
offsets,
|
|
|
|
|
totalHeight,
|
|
|
|
|
scrollableContainerHeight,
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-02-27 08:00:07 -08:00
|
|
|
}
|
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);
|
2026-04-06 21:30:30 -07:00
|
|
|
setScrollAnchor(
|
|
|
|
|
getAnchorForScrollTop(
|
|
|
|
|
newScrollTop,
|
|
|
|
|
offsets,
|
|
|
|
|
totalHeight,
|
|
|
|
|
scrollableContainerHeight,
|
|
|
|
|
),
|
|
|
|
|
);
|
2025-11-04 16:21:00 -08:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
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);
|
2026-04-06 21:30:30 -07:00
|
|
|
setScrollAnchor(
|
|
|
|
|
getAnchorForScrollTop(
|
|
|
|
|
newScrollTop,
|
|
|
|
|
offsets,
|
|
|
|
|
totalHeight,
|
|
|
|
|
scrollableContainerHeight,
|
|
|
|
|
),
|
|
|
|
|
);
|
2025-11-04 16:21:00 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
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-04-06 21:30:30 -07:00
|
|
|
overflowY="scroll"
|
2025-11-04 16:21:00 -08:00
|
|
|
overflowX="hidden"
|
2026-04-06 21:30:30 -07:00
|
|
|
scrollTop={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-04-06 21:30:30 -07:00
|
|
|
paddingRight={1}
|
2026-04-02 17:39:49 -07:00
|
|
|
overflowToBackbuffer={overflowToBackbuffer}
|
|
|
|
|
scrollbar={scrollbar}
|
|
|
|
|
stableScrollback={stableScrollback}
|
2025-11-04 16:21:00 -08:00
|
|
|
>
|
2026-04-06 21:30:30 -07:00
|
|
|
<Box flexShrink={0} width="100%" flexDirection="column">
|
|
|
|
|
{topSpacerHeight > 0 ? (
|
|
|
|
|
<Box height={topSpacerHeight} flexShrink={0} />
|
|
|
|
|
) : null}
|
2025-11-04 16:21:00 -08:00
|
|
|
{renderedItems}
|
2026-04-06 21:30:30 -07:00
|
|
|
{bottomSpacerHeight > 0 ? (
|
|
|
|
|
<Box height={bottomSpacerHeight} flexShrink={0} />
|
|
|
|
|
) : null}
|
2025-11-04 16:21:00 -08:00
|
|
|
</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';
|