Optimize VirtualizedList

Checkpoint optimizing virtualized list

Fixes for fallback rendering where terminalBuffer=false
Change terminalBuffer false back to the default while we fix performance
with very large chats.

Checkpoint changes to virtualized list.

Fix virtualized list

NO commit

Update ink version.

Fix UI snapshot mismatch in MainContent tests and VirtualizedList computation

Checkpoint.

Optimize scrolling checkpoint

Fix tests and resolve remaining issues after rebase

- Fixed ToolStickyHeaderRegression scrolling logic.
- Added MouseProvider to VirtualizedList test.
- Updated snapshots to reflect layout changes in optimized virtual list.

Fix flaky plan-mode test by removing model request assertions

Fix unit tests by updating expected decisions for tool permissions after rebase

use onStaticRender in VirtualizedList

Checkpoint VirtualizedListClick

Update version

Bug fixes.

Code review comment fixes.

settings.json hack

Update version and local tweaks.
This commit is contained in:
jacob314
2026-04-06 21:30:30 -07:00
parent f8541cf7a2
commit 44259a00e2
53 changed files with 3379 additions and 545 deletions
@@ -0,0 +1,330 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useRef,
forwardRef,
useImperativeHandle,
useCallback,
useMemo,
useEffect,
useContext,
useLayoutEffect,
} from 'react';
import type React from 'react';
import {
FixedVirtualizedList,
type FixedVirtualizedListRef,
type FixedVirtualizedListProps,
SCROLL_TO_ITEM_END,
} from './FixedVirtualizedList.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { Box, type DOMElement } from 'ink';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { VirtualizedListContext } from './VirtualizedList.js';
const ANIMATION_FRAME_DURATION_MS = 33;
interface FixedScrollableListProps<T> extends FixedVirtualizedListProps<T> {
itemKey?: string;
hasFocus: boolean;
width: number;
scrollbar?: boolean;
stableScrollback?: boolean;
isStatic?: boolean;
fixedItemHeight?: boolean;
targetScrollIndex?: number;
scrollbarThumbColor?: string;
}
export type FixedScrollableListRef<T> = FixedVirtualizedListRef<T>;
function FixedScrollableList<T>(
props: FixedScrollableListProps<T>,
ref: React.Ref<FixedScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
const settings = useSettings();
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
const {
itemKey,
hasFocus,
width,
maxHeight,
scrollbar = true,
stableScrollback,
} = props;
const fixedVirtualizedListRef = useRef<FixedVirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
const virtualizedListContext = useContext(VirtualizedListContext);
useLayoutEffect(() => {
if (itemKey && virtualizedListContext) {
const restoredTop = virtualizedListContext.getItemState(
itemKey,
'scrollTop',
);
if (typeof restoredTop === 'number') {
fixedVirtualizedListRef.current?.scrollTo(restoredTop);
}
}
}, [itemKey, virtualizedListContext]);
useEffect(
() => () => {
if (itemKey && virtualizedListContext) {
const top = fixedVirtualizedListRef.current?.getScrollState().scrollTop;
if (top !== undefined) {
virtualizedListContext.setItemState(itemKey, 'scrollTop', top);
}
}
},
[itemKey, virtualizedListContext],
);
useImperativeHandle(
ref,
() => ({
scrollBy: (delta) => fixedVirtualizedListRef.current?.scrollBy(delta),
scrollTo: (offset) => fixedVirtualizedListRef.current?.scrollTo(offset),
scrollToEnd: () => fixedVirtualizedListRef.current?.scrollToEnd(),
scrollToIndex: (params) =>
fixedVirtualizedListRef.current?.scrollToIndex(params),
scrollToItem: (params) =>
fixedVirtualizedListRef.current?.scrollToItem(params),
getScrollIndex: () =>
fixedVirtualizedListRef.current?.getScrollIndex() ?? 0,
getScrollState: () =>
fixedVirtualizedListRef.current?.getScrollState() ?? {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
},
}),
[],
);
const getScrollState = useCallback(
() =>
fixedVirtualizedListRef.current?.getScrollState() ?? {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
},
[],
);
const scrollBy = useCallback((delta: number) => {
fixedVirtualizedListRef.current?.scrollBy(delta);
}, []);
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
useAnimatedScrollbar(hasFocus, scrollBy);
const smoothScrollState = useRef<{
active: boolean;
start: number;
from: number;
to: number;
duration: number;
timer: NodeJS.Timeout | null;
}>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null });
const stopSmoothScroll = useCallback(() => {
if (smoothScrollState.current.timer) {
clearInterval(smoothScrollState.current.timer);
smoothScrollState.current.timer = null;
}
smoothScrollState.current.active = false;
}, []);
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
const smoothScrollTo = useCallback(
(
targetScrollTop: number,
duration: number = process.env['NODE_ENV'] === 'test' ? 0 : 200,
) => {
stopSmoothScroll();
const scrollState = fixedVirtualizedListRef.current?.getScrollState() ?? {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
};
const {
scrollTop: rawStartScrollTop,
scrollHeight,
innerHeight,
} = scrollState;
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop);
let effectiveTarget = targetScrollTop;
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
effectiveTarget = maxScrollTop;
}
const clampedTarget = Math.max(
0,
Math.min(maxScrollTop, effectiveTarget),
);
if (duration === 0) {
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
fixedVirtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
} else {
fixedVirtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
}
flashScrollbar();
return;
}
smoothScrollState.current = {
active: true,
start: Date.now(),
from: startScrollTop,
to: clampedTarget,
duration,
timer: setInterval(() => {
const now = Date.now();
const elapsed = now - smoothScrollState.current.start;
const progress = Math.min(elapsed / duration, 1);
// Ease-in-out
const t = progress;
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
const current =
smoothScrollState.current.from +
(smoothScrollState.current.to - smoothScrollState.current.from) *
ease;
if (progress >= 1) {
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
fixedVirtualizedListRef.current?.scrollTo(
Number.MAX_SAFE_INTEGER,
);
} else {
fixedVirtualizedListRef.current?.scrollTo(Math.round(current));
}
stopSmoothScroll();
flashScrollbar();
} else {
fixedVirtualizedListRef.current?.scrollTo(Math.round(current));
}
}, ANIMATION_FRAME_DURATION_MS),
};
},
[stopSmoothScroll, flashScrollbar],
);
useKeypress(
(key: Key) => {
if (keyMatchers[Command.SCROLL_UP](key)) {
stopSmoothScroll();
scrollByWithAnimation(-1);
return true;
} else if (keyMatchers[Command.SCROLL_DOWN](key)) {
stopSmoothScroll();
scrollByWithAnimation(1);
return true;
} else if (
keyMatchers[Command.PAGE_UP](key) ||
keyMatchers[Command.PAGE_DOWN](key)
) {
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
const scrollState = getScrollState();
const maxScroll = Math.max(
0,
scrollState.scrollHeight - scrollState.innerHeight,
);
const current = smoothScrollState.current.active
? smoothScrollState.current.to
: Math.min(scrollState.scrollTop, maxScroll);
const innerHeight = scrollState.innerHeight;
smoothScrollTo(current + direction * innerHeight);
return true;
} else if (keyMatchers[Command.SCROLL_HOME](key)) {
smoothScrollTo(0);
return true;
} else if (keyMatchers[Command.SCROLL_END](key)) {
smoothScrollTo(SCROLL_TO_ITEM_END);
return true;
}
return false;
},
{ isActive: hasFocus },
);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
const scrollableEntry = useMemo(
() => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ref: containerRef as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
scrollTo: smoothScrollTo,
hasFocus: hasFocusCallback,
flashScrollbar,
}),
[
getScrollState,
hasFocusCallback,
flashScrollbar,
scrollByWithAnimation,
smoothScrollTo,
],
);
useScrollable(scrollableEntry, true);
return (
<Box
ref={containerRef}
flexGrow={1}
flexDirection="column"
width={width}
maxHeight={maxHeight}
>
<FixedVirtualizedList
ref={fixedVirtualizedListRef}
{...props}
scrollbar={scrollbar}
scrollbarThumbColor={scrollbarColor}
stableScrollback={stableScrollback}
maxScrollbackLength={maxScrollbackLength}
/>
</Box>
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const FixedScrollableListWithForwardRef = forwardRef(FixedScrollableList) as <
T,
>(
props: FixedScrollableListProps<T> & {
ref?: React.Ref<FixedScrollableListRef<T>>;
},
) => React.ReactElement;
export { FixedScrollableListWithForwardRef as FixedScrollableList };
@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { Box, Text } from 'ink';
import { describe, expect, it } from 'vitest';
import { renderWithProviders as render } from '../../../test-utils/render.js';
import {
FixedVirtualizedList,
SCROLL_TO_ITEM_END,
} from './FixedVirtualizedList.js';
describe('<FixedVirtualizedList />', () => {
const renderList = (data: string[]) => (
<Box height={5} width={80}>
<FixedVirtualizedList
data={data}
renderItem={({ item }) => (
<Box height={1}>
<Text>{item}</Text>
</Box>
)}
itemHeight={1}
keyExtractor={(item) => item}
initialScrollIndex={SCROLL_TO_ITEM_END}
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
width={80}
maxHeight={5}
/>
</Box>
);
it('sticks to the bottom when data grows', async () => {
const initialData = Array.from({ length: 10 }, (_, i) => `Item ${i}`);
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
renderList(initialData),
);
await waitUntilReady();
expect(lastFrame()).toContain('Item 9');
const newData = [...initialData, 'Item 10', 'Item 11'];
await act(async () => {
rerender(renderList(newData));
});
await waitUntilReady();
expect(lastFrame()).toContain('Item 11');
expect(lastFrame()).not.toContain('Item 0');
unmount();
});
});
@@ -0,0 +1,616 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useState,
useRef,
forwardRef,
useImperativeHandle,
useMemo,
useCallback,
memo,
} from 'react';
import type React from 'react';
import { theme } from '../../semantic-colors.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { Box, StaticRender } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
export type FixedVirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
itemHeight: number;
keyExtractor: (item: T, index: number) => string;
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
targetScrollIndex?: number;
backgroundColor?: string;
scrollbarThumbColor?: string;
renderStatic?: boolean;
isStaticItem?: (item: T, index: number) => boolean;
width: number;
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
maxHeight: number;
maxScrollbackLength?: number;
};
export type FixedVirtualizedListRef<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;
};
};
const FixedVirtualizedListItem = memo(
({
content,
shouldBeStatic,
width,
itemKey,
}: {
content: React.ReactElement;
shouldBeStatic: boolean;
width: number;
itemKey: string;
}) => (
<Box width="100%" flexDirection="column" flexShrink={0}>
{shouldBeStatic ? (
<StaticRender width={width} key={itemKey + '-static-' + width}>
{() => content}
</StaticRender>
) : (
content
)}
</Box>
),
);
FixedVirtualizedListItem.displayName = 'FixedVirtualizedListItem';
function FixedVirtualizedList<T>(
props: FixedVirtualizedListProps<T>,
ref: React.Ref<FixedVirtualizedListRef<T>>,
) {
const {
data,
renderItem,
itemHeight,
keyExtractor,
initialScrollIndex,
initialScrollOffsetInIndex,
renderStatic,
isStaticItem,
width,
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
maxScrollbackLength,
maxHeight,
} = props;
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,
};
}
if (typeof props.targetScrollIndex === 'number') {
return {
index: props.targetScrollIndex,
offset: 0,
};
}
return { index: 0, offset: 0 };
});
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;
});
const totalHeight = data.length * itemHeight;
const scrollableContainerHeight = maxHeight;
const isInitialScrollSet = useRef(false);
const getAnchorForScrollTop = useCallback(
(scrollTop: number): { index: number; offset: number } => {
const index = Math.max(
0,
Math.min(data.length - 1, Math.floor(scrollTop / itemHeight)),
);
if (data.length === 0) {
return { index: 0, offset: 0 };
}
return { index, offset: scrollTop - index * itemHeight };
},
[data.length, itemHeight],
);
const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState(
props.targetScrollIndex,
);
const prevDataLength = useRef(data.length);
const previousDataLength = prevDataLength.current;
if (
(props.targetScrollIndex !== undefined &&
props.targetScrollIndex !== prevTargetScrollIndex &&
data.length > 0) ||
(props.targetScrollIndex !== undefined &&
previousDataLength === 0 &&
data.length > 0)
) {
if (props.targetScrollIndex !== prevTargetScrollIndex) {
setPrevTargetScrollIndex(props.targetScrollIndex);
}
setIsStickingToBottom(false);
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
}
const rawStateActualScrollTop = (() => {
const offset = scrollAnchor.index * itemHeight;
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
return offset + itemHeight - scrollableContainerHeight;
}
return offset + scrollAnchor.offset;
})();
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
const stateActualScrollTop = Math.max(
0,
Math.min(maxScroll, rawStateActualScrollTop),
);
const prevTotalHeight = useRef(totalHeight);
const prevScrollTop = useRef(rawStateActualScrollTop);
const prevContainerHeight = useRef(scrollableContainerHeight);
// Render-time state derivation to avoid useEffect for static rendering
let currentScrollAnchor = scrollAnchor;
let currentIsStickingToBottom = isStickingToBottom;
const contentPreviouslyFit =
prevTotalHeight.current <= prevContainerHeight.current;
const wasScrolledToBottomPixels =
prevScrollTop.current >=
prevTotalHeight.current - prevContainerHeight.current - 1;
// Crucial fix: we were previously only evaluating wasAtBottom against rawStateActualScrollTop *if* it was at bottom *last* frame.
// But if the content just exceeded the container height, wasScrolledToBottomPixels is false, but contentPreviouslyFit is true.
// If it previously fit, it implicitly means we should stick to the bottom if the new height exceeds the container.
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
if (
wasAtBottom &&
(rawStateActualScrollTop >= prevScrollTop.current || contentPreviouslyFit)
) {
if (!currentIsStickingToBottom) {
currentIsStickingToBottom = true;
if (scrollAnchor === currentScrollAnchor) {
// Avoid infinite loop if we already updated state
setIsStickingToBottom(true);
}
}
}
const listGrew = data.length > previousDataLength;
const containerChanged =
prevContainerHeight.current !== scrollableContainerHeight;
const shouldAutoScroll = props.targetScrollIndex === undefined;
if (
shouldAutoScroll &&
((listGrew && (currentIsStickingToBottom || wasAtBottom)) ||
(currentIsStickingToBottom && containerChanged))
) {
const newIndex = data.length > 0 ? data.length - 1 : 0;
if (
currentScrollAnchor.index !== newIndex ||
currentScrollAnchor.offset !== SCROLL_TO_ITEM_END
) {
currentScrollAnchor = {
index: newIndex,
offset: SCROLL_TO_ITEM_END,
};
setScrollAnchor(currentScrollAnchor);
}
if (!currentIsStickingToBottom) {
currentIsStickingToBottom = true;
setIsStickingToBottom(true);
}
} else if (
(currentScrollAnchor.index >= data.length ||
stateActualScrollTop > totalHeight - scrollableContainerHeight) &&
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
const newAnchor = getAnchorForScrollTop(newScrollTop);
if (
currentScrollAnchor.index !== newAnchor.index ||
currentScrollAnchor.offset !== newAnchor.offset
) {
currentScrollAnchor = newAnchor;
setScrollAnchor(newAnchor);
}
} else if (data.length === 0) {
if (currentScrollAnchor.index !== 0 || currentScrollAnchor.offset !== 0) {
currentScrollAnchor = { index: 0, offset: 0 };
setScrollAnchor(currentScrollAnchor);
}
}
// Initial scroll setup during render
if (
!isInitialScrollSet.current &&
data.length > 0 &&
totalHeight > 0 &&
scrollableContainerHeight > 0
) {
if (props.targetScrollIndex !== undefined) {
isInitialScrollSet.current = true;
} else if (typeof initialScrollIndex === 'number') {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
(initialScrollIndex >= data.length - 1 &&
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
if (scrollToEnd) {
currentScrollAnchor = {
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
};
setScrollAnchor(currentScrollAnchor);
currentIsStickingToBottom = true;
setIsStickingToBottom(true);
isInitialScrollSet.current = true;
} else {
const index = Math.max(
0,
Math.min(data.length - 1, initialScrollIndex),
);
const offset = initialScrollOffsetInIndex ?? 0;
const newScrollTop = index * itemHeight + offset;
const clampedScrollTop = Math.max(
0,
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
);
currentScrollAnchor = getAnchorForScrollTop(clampedScrollTop);
setScrollAnchor(currentScrollAnchor);
isInitialScrollSet.current = true;
}
}
}
// After all derived state updates, update refs for the next render
prevDataLength.current = data.length;
prevTotalHeight.current = totalHeight;
const rawDerivedActualScrollTop = (() => {
const offset = currentScrollAnchor.index * itemHeight;
if (currentScrollAnchor.offset === SCROLL_TO_ITEM_END) {
return offset + itemHeight - scrollableContainerHeight;
}
return offset + currentScrollAnchor.offset;
})();
const derivedActualScrollTop = Math.max(
0,
Math.min(maxScroll, rawDerivedActualScrollTop),
);
prevScrollTop.current = rawDerivedActualScrollTop;
prevContainerHeight.current = scrollableContainerHeight;
const startIndex = Math.max(
0,
Math.floor(derivedActualScrollTop / itemHeight) - 1,
);
const viewHeightForEndIndex =
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
const maxEndIndex = data.length - 1;
const endIndex = Math.min(
maxEndIndex,
Math.ceil((derivedActualScrollTop + viewHeightForEndIndex) / itemHeight),
);
const culledHeight = useMemo(() => {
if (
overflowToBackbuffer &&
typeof maxScrollbackLength === 'number' &&
maxScrollbackLength > 0
) {
// Keep maxScrollbackLength items before the viewport.
// We add 1 to startIndex to account for the 1-item overscan it includes.
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
return targetIndex * itemHeight;
}
return 0;
}, [overflowToBackbuffer, maxScrollbackLength, startIndex, itemHeight]);
const scrollTop = currentIsStickingToBottom
? Number.MAX_SAFE_INTEGER
: Math.max(0, derivedActualScrollTop - culledHeight);
const renderRangeStart = (() => {
if (renderStatic) return 0;
if (overflowToBackbuffer) {
if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) {
// Render from the culled boundary.
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
return targetIndex;
}
return 0;
}
return startIndex;
})();
const renderRangeEnd = renderStatic ? maxEndIndex : endIndex;
const topSpacerHeight = Math.max(
0,
renderRangeStart * itemHeight - culledHeight,
);
const bottomSpacerHeight = renderStatic
? 0
: totalHeight - (renderRangeEnd + 1) * itemHeight;
const renderedItems = useMemo(() => {
const items = [];
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);
items.push(
<FixedVirtualizedListItem
key={key}
itemKey={key}
content={content}
shouldBeStatic={shouldBeStatic}
width={width}
/>,
);
}
}
return items;
}, [
renderRangeStart,
renderRangeEnd,
data,
startIndex,
endIndex,
renderStatic,
isStaticItem,
renderItem,
keyExtractor,
width,
]);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
useImperativeHandle(
ref,
() => ({
scrollBy: (delta: number) => {
if (delta < 0) {
setIsStickingToBottom(false);
}
const currentScrollTop = getScrollTop();
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;
}
setPendingScrollTop(newScrollTop);
setScrollAnchor(
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll)),
);
},
scrollTo: (offset: number) => {
const effectiveTotalHeight = totalHeight - culledHeight;
const maxScroll = Math.max(
0,
effectiveTotalHeight - 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 + culledHeight);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
}
},
scrollToEnd: () => {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
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 = index * itemHeight;
if (index >= 0 && index < data.length) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
maxScroll,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
}
},
scrollToItem: ({
item,
viewOffset = 0,
viewPosition = 0,
}: {
item: T;
viewOffset?: number;
viewPosition?: number;
}) => {
setIsStickingToBottom(false);
const index = data.indexOf(item);
if (index !== -1) {
const offset = index * itemHeight;
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
maxScroll,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
}
},
getScrollIndex: () => scrollAnchor.index,
getScrollState: () => {
const effectiveTotalHeight = totalHeight - culledHeight;
const maxScroll = Math.max(
0,
effectiveTotalHeight - scrollableContainerHeight,
);
return {
scrollTop: Math.min(
Math.max(0, getScrollTop() - culledHeight),
maxScroll,
),
scrollHeight: effectiveTotalHeight,
innerHeight: scrollableContainerHeight,
};
},
}),
[
scrollAnchor,
totalHeight,
getAnchorForScrollTop,
data,
scrollableContainerHeight,
getScrollTop,
setPendingScrollTop,
itemHeight,
culledHeight,
],
);
return (
<Box
overflowY="scroll"
overflowX="hidden"
scrollTop={
isStickingToBottom
? Number.MAX_SAFE_INTEGER
: Math.max(0, getScrollTop() - culledHeight)
}
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
backgroundColor={props.backgroundColor}
width="100%"
height="100%"
flexDirection="column"
paddingRight={1}
overflowToBackbuffer={overflowToBackbuffer}
scrollbar={scrollbar}
stableScrollback={stableScrollback}
>
<Box flexShrink={0} width="100%" flexDirection="column">
<Box height={topSpacerHeight} flexShrink={0} />
{renderedItems}
<Box height={Math.max(0, bottomSpacerHeight)} flexShrink={0} />
</Box>
</Box>
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const FixedVirtualizedListWithForwardRef = forwardRef(FixedVirtualizedList) as <
T,
>(
props: FixedVirtualizedListProps<T> & {
ref?: React.Ref<FixedVirtualizedListRef<T>>;
},
) => React.ReactElement;
export { FixedVirtualizedListWithForwardRef as FixedVirtualizedList };
FixedVirtualizedList.displayName = 'FixedVirtualizedList';
@@ -13,6 +13,7 @@ import {
useLayoutEffect,
useEffect,
useId,
useContext,
} from 'react';
import { Box, ResizeObserver, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
@@ -22,9 +23,11 @@ import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { Command } from '../../key/keyMatchers.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { VirtualizedListContext } from './VirtualizedList.js';
interface ScrollableProps {
children?: React.ReactNode;
itemKey?: string;
width?: number;
height?: number | string;
maxWidth?: number;
@@ -40,6 +43,7 @@ interface ScrollableProps {
export const Scrollable: React.FC<ScrollableProps> = ({
children,
itemKey,
width,
height,
maxWidth,
@@ -53,7 +57,16 @@ export const Scrollable: React.FC<ScrollableProps> = ({
stableScrollback,
}) => {
const keyMatchers = useKeyMatchers();
const [scrollTop, setScrollTop] = useState(0);
const virtualizedListContext = useContext(VirtualizedListContext);
const [scrollTop, setScrollTop] = useState(() => {
if (itemKey && virtualizedListContext) {
const state = virtualizedListContext.getItemState(itemKey, 'scrollTop');
return typeof state === 'number' ? state : 0;
}
return 0;
});
const viewportRef = useRef<DOMElement | null>(null);
const contentRef = useRef<DOMElement | null>(null);
const overflowActions = useOverflowActions();
@@ -73,6 +86,19 @@ export const Scrollable: React.FC<ScrollableProps> = ({
scrollTopRef.current = scrollTop;
}, [scrollTop]);
useEffect(
() => () => {
if (itemKey && virtualizedListContext) {
virtualizedListContext.setItemState(
itemKey,
'scrollTop',
scrollTopRef.current,
);
}
},
[itemKey, virtualizedListContext],
);
useEffect(() => {
if (reportOverflow && size.scrollHeight > size.innerHeight) {
overflowActions?.addOverflowingId?.(id);
@@ -11,6 +11,8 @@ import {
useCallback,
useMemo,
useLayoutEffect,
useEffect,
useContext,
} from 'react';
import type React from 'react';
import {
@@ -25,17 +27,18 @@ import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { VirtualizedListContext } from './VirtualizedList.js';
const ANIMATION_FRAME_DURATION_MS = 33;
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
itemKey?: string;
hasFocus: boolean;
width?: string | number;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
isStatic?: boolean;
fixedItemHeight?: boolean;
targetScrollIndex?: number;
containerHeight?: number;
scrollbarThumbColor?: string;
@@ -48,10 +51,44 @@ function ScrollableList<T>(
ref: React.Ref<ScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
const settings = useSettings();
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
const {
hasFocus,
width,
scrollbar = true,
stableScrollback,
itemKey,
} = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
const virtualizedListContext = useContext(VirtualizedListContext);
useLayoutEffect(() => {
if (itemKey && virtualizedListContext) {
const restoredTop = virtualizedListContext.getItemState(
itemKey,
'scrollTop',
);
if (typeof restoredTop === 'number') {
virtualizedListRef.current?.scrollTo(restoredTop);
}
}
}, [itemKey, virtualizedListContext]);
useEffect(
() => () => {
if (itemKey && virtualizedListContext) {
const top = virtualizedListRef.current?.getScrollState().scrollTop;
if (top !== undefined) {
virtualizedListContext.setItemState(itemKey, 'scrollTop', top);
}
}
},
[itemKey, virtualizedListContext],
);
useImperativeHandle(
ref,
() => ({
@@ -265,6 +302,7 @@ function ScrollableList<T>(
scrollbar={scrollbar}
scrollbarThumbColor={scrollbarColor}
stableScrollback={stableScrollback}
maxScrollbackLength={maxScrollbackLength}
/>
</Box>
);
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders as render } from '../../../test-utils/render.js';
import { VirtualizedList } from './VirtualizedList.js';
import type { VirtualizedListRef } from './VirtualizedList.js';
import { Text, Box } from 'ink';
import { describe, it, expect } from 'vitest';
import { createRef } from 'react';
describe('<VirtualizedList /> backbuffer regression', () => {
const keyExtractor = (item: string) => item;
it('provides a sufficient history buffer regardless of height estimation', async () => {
// 1000 items, each 1 line high.
const data = Array.from(
{ length: 1000 },
(_, i) => `Item ${String(i).padStart(3, '0')}`,
);
const ref = createRef<VirtualizedListRef<string>>();
const { waitUntilReady, unmount } = await render(
<Box height={50} width={100}>
<VirtualizedList
ref={ref}
data={data}
renderItem={({ item }) => (
<Box height={1}>
<Text>{item}</Text>
</Box>
)}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 10}
initialScrollIndex={999}
overflowToBackbuffer={true}
renderStatic={true}
maxScrollbackLength={150}
/>
</Box>,
);
await waitUntilReady();
try {
const state = ref.current?.getScrollState();
// Viewport is 50, backbuffer is 150.
// Total scrollHeight should be AT LEAST 200 lines.
// Since our fix is item-based, and items are 1 line high, it should be
// exactly or very close to 200.
expect(state?.scrollHeight).toBeGreaterThanOrEqual(200);
expect(state?.innerHeight).toBe(50);
} finally {
unmount();
}
});
});
@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders as render } from '../../../test-utils/render.js';
import { VirtualizedList } from './VirtualizedList.js';
import { Text, Box } from 'ink';
import { describe, it, expect } from 'vitest';
describe('<VirtualizedList /> fallback', () => {
const keyExtractor = (item: string) => item;
it('uses default maxScrollbackLength of 1000 when not provided', async () => {
const longData = Array.from({ length: 2000 }, (_, i) => `Item ${i}`);
const renderedIndices = new Set<number>();
const renderItem1px = ({
item,
index,
}: {
item: string;
index: number;
}) => {
renderedIndices.add(index);
return (
<Box height={1}>
<Text>{item}</Text>
</Box>
);
};
const { unmount } = await render(
<Box height={10} width={100}>
<VirtualizedList
data={longData}
renderItem={renderItem1px}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
initialScrollIndex={1999}
overflowToBackbuffer={true}
// maxScrollbackLength is NOT provided
/>
</Box>,
);
// Viewport height is 10.
// initialScrollIndex is 1999.
// actualScrollTop = 2000 - 10 = 1990.
// Default fallback maxScrollbackLength = 1000.
// targetOffset = 1990 - 1000 = 990.
// renderRangeStart should be around 989/990.
// Items below 980 should NOT be rendered.
// Items around 1000 SHOULD be rendered.
// Check viewport items are rendered
expect(renderedIndices.has(1995)).toBe(true);
expect(renderedIndices.has(1999)).toBe(true);
// Check items in maxScrollbackLength (1000) are rendered
expect(renderedIndices.has(1000)).toBe(true);
expect(renderedIndices.has(1100)).toBe(true);
// Check items beyond maxScrollbackLength are NOT rendered
expect(renderedIndices.has(0)).toBe(false);
expect(renderedIndices.has(500)).toBe(false);
expect(renderedIndices.has(900)).toBe(false);
unmount();
});
});
@@ -4,9 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { renderWithProviders as render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
import {
SCROLL_TO_ITEM_END,
VirtualizedList,
type VirtualizedListRef,
} from './VirtualizedList.js';
import { Text, Box } from 'ink';
import {
createRef,
@@ -115,6 +119,41 @@ describe('<VirtualizedList />', () => {
unmount();
});
it('rerenders cached items when renderItem changes', async () => {
const data = ['Item 0'];
const renderWithLabel = (label: string) => (
<Box height={10} width={100}>
<VirtualizedList
data={data}
renderItem={({ item }) => (
<Box height={1}>
<Text>
{label} {item}
</Text>
</Box>
)}
keyExtractor={keyExtractor}
estimatedItemHeight={() => itemHeight}
/>
</Box>
);
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
renderWithLabel('Initial'),
);
await waitUntilReady();
expect(lastFrame()).toContain('Initial Item 0');
await act(async () => {
rerender(renderWithLabel('Updated'));
});
await waitUntilReady();
expect(lastFrame()).toContain('Updated Item 0');
expect(lastFrame()).not.toContain('Initial Item 0');
unmount();
});
it('scrolls down to show new items when requested via ref', async () => {
const ref = createRef<VirtualizedListRef<string>>();
const { lastFrame, waitUntilReady, unmount } = await render(
@@ -170,7 +209,7 @@ describe('<VirtualizedList />', () => {
(_, i) => `Item ${i}`,
);
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<Box height={20} width={100} borderStyle="round">
<VirtualizedList
data={veryLongData}
@@ -184,8 +223,12 @@ describe('<VirtualizedList />', () => {
</Box>,
);
await waitUntilReady();
await waitFor(() => {
expect(mountedCount).toBe(expectedMountedCount);
});
const frame = lastFrame();
expect(mountedCount).toBe(expectedMountedCount);
expect(frame).toMatchSnapshot();
unmount();
},
@@ -316,12 +359,69 @@ describe('<VirtualizedList />', () => {
unmount();
});
it('renders correctly in copyModeEnabled when scrolled', async () => {
it('culls items that exceed maxScrollbackLength when overflowToBackbuffer is true', async () => {
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
// Use copy mode
const { lastFrame, unmount } = await render(
const renderedIndices = new Set<number>();
const renderItem1px = ({
item,
index,
}: {
item: string;
index: number;
}) => {
renderedIndices.add(index);
return (
<Box height={1}>
<Text>{item}</Text>
</Box>
);
};
const { unmount } = await render(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
data={longData}
renderItem={renderItem1px}
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={99}
overflowToBackbuffer={true}
maxScrollbackLength={10}
/>
</Box>,
);
// Viewport height is 10, total items = 100.
// actualScrollTop = 92 (due to top/bottom borders taking 2 lines out of 10, inner height 8).
// wait, if height is 10 with round border, inner height is 8.
// actualScrollTop = 100 - 8 = 92.
// maxScrollbackLength = 10.
// targetOffset = 92 - 10 = 82.
// So renderRangeStart should be 81 (or 82).
// Items 0 to 80 should not be rendered!
// Check viewport items are rendered
expect(renderedIndices.has(95)).toBe(true);
expect(renderedIndices.has(99)).toBe(true);
// Check items in maxScrollbackLength are rendered
expect(renderedIndices.has(85)).toBe(true);
// Check items beyond maxScrollbackLength are NOT rendered
expect(renderedIndices.has(0)).toBe(false);
expect(renderedIndices.has(50)).toBe(false);
expect(renderedIndices.has(75)).toBe(false);
unmount();
});
it('crops the document height when maxScrollbackLength is exceeded', async () => {
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
const ref = createRef<VirtualizedListRef<string>>();
const { unmount, waitUntilReady } = await render(
<Box height={10} width={100}>
<VirtualizedList
ref={ref}
data={longData}
renderItem={({ item }) => (
<Box height={1}>
@@ -330,18 +430,243 @@ describe('<VirtualizedList />', () => {
)}
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={50}
copyModeEnabled={true}
initialScrollIndex={99}
overflowToBackbuffer={true}
maxScrollbackLength={10}
/>
</Box>,
);
// Item 50 should be visible
expect(lastFrame()).toContain('Item 50');
// And surrounding items
expect(lastFrame()).toContain('Item 59');
// But far away items should not be (ensures we are actually scrolled)
expect(lastFrame()).not.toContain('Item 0');
await waitUntilReady();
// Viewport height is 10.
// maxScrollbackLength = 10.
// Total expected scrollHeight = 10 + 10 = 20.
const state = ref.current?.getScrollState();
expect(state?.scrollHeight).toBe(20);
// The top of the projected document (offset 0) should correspond to absolute offset 80.
// getAnchorForScrollTop(80) will return index 90 because it's near the bottom and uses a bottom anchor.
await act(async () => {
ref.current?.scrollTo(0);
});
expect(ref.current?.getScrollIndex()).toBe(90);
unmount();
});
it('culls the backbuffer by measured row height instead of item count', async () => {
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
const renderedIndices = new Set<number>();
const ref = createRef<VirtualizedListRef<string>>();
const { unmount, waitUntilReady } = await render(
<Box height={10} width={100}>
<VirtualizedList
ref={ref}
data={longData}
renderItem={({ item, index }) => {
renderedIndices.add(index);
return (
<Box height={2}>
<Text>{item}</Text>
</Box>
);
}}
keyExtractor={(item) => item}
estimatedItemHeight={() => 2}
initialScrollIndex={99}
overflowToBackbuffer={true}
maxScrollbackLength={10}
/>
</Box>,
);
await waitUntilReady();
const state = ref.current?.getScrollState();
expect(state?.scrollHeight).toBe(20);
expect(state?.innerHeight).toBe(10);
expect(renderedIndices.has(90)).toBe(true);
expect(renderedIndices.has(85)).toBe(false);
unmount();
});
it('keeps keyboard scrolling in logical history coordinates after culling', async () => {
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
const ref = createRef<VirtualizedListRef<string>>();
const { lastFrame, unmount, waitUntilReady } = await render(
<Box height={10} width={100}>
<VirtualizedList
ref={ref}
data={longData}
renderItem={({ item }) => (
<Box height={1}>
<Text>{item}</Text>
</Box>
)}
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={99}
overflowToBackbuffer={true}
maxScrollbackLength={10}
/>
</Box>,
);
await waitUntilReady();
expect(ref.current?.getScrollState().scrollTop).toBe(10);
await act(async () => {
ref.current?.scrollBy(-1);
});
await waitUntilReady();
const state = ref.current?.getScrollState();
expect(state?.scrollTop).toBeGreaterThan(0);
expect(lastFrame()).not.toContain('Item 79');
expect(lastFrame()).not.toContain('Item 80');
unmount();
});
it('measures mounted zero-height items instead of keeping their estimate', async () => {
const ref = createRef<VirtualizedListRef<string>>();
const data = ['Item 0', 'Item 1', 'pending'];
const { unmount, waitUntilReady } = await render(
<Box height={50} width={100}>
<VirtualizedList
ref={ref}
data={data}
renderItem={({ item }) =>
item === 'pending' ? (
<Box height={0} />
) : (
<Box height={1}>
<Text>{item}</Text>
</Box>
)
}
keyExtractor={(item) => item}
estimatedItemHeight={() => 10}
initialScrollIndex={2}
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
/>
</Box>,
);
await waitUntilReady();
expect(ref.current?.getScrollState()).toEqual({
scrollTop: 0,
scrollHeight: 2,
innerHeight: 50,
});
unmount();
});
it('does not forget item heights when items are prepended', async () => {
const ref = createRef<VirtualizedListRef<string>>();
const data = ['Item 1', 'Item 2'];
const { rerender, waitUntilReady, unmount } = await render(
<Box height={10} width={100}>
<VirtualizedList
ref={ref}
data={data}
renderItem={({ item }) => (
<Box height={1}>
<Text>{item}</Text>
</Box>
)}
keyExtractor={(item) => item}
estimatedItemHeight={() => 1000}
/>
</Box>,
);
await waitUntilReady();
await waitFor(() => {
// Item 1 and 2 measured. totalHeight = 2.
expect(ref.current?.getScrollState().scrollHeight).toBe(2);
});
// Prepend Item 0
const newData = ['Item 0', 'Item 1', 'Item 2'];
await act(async () => {
rerender(
<Box height={10} width={100}>
<VirtualizedList
ref={ref}
data={newData}
renderItem={({ item }) => (
<Box height={1}>
<Text>{item}</Text>
</Box>
)}
keyExtractor={(item) => item}
estimatedItemHeight={() => 1000}
/>
</Box>,
);
});
// With the Map-based cache, Item 1 and 2 heights (1 each) should be preserved
// even though their indices changed.
// Item 0 is new and uses estimate 1000.
// So totalHeight should be 1002 (before Item 0 is measured).
// Note: It might already be 3 if Item 0 was measured immediately, but it
// definitely shouldn't be 3000 (which it would be if Item 1 and 2 were forgotten).
const scrollHeight = ref.current?.getScrollState().scrollHeight;
expect(scrollHeight).toBeGreaterThan(0);
expect(scrollHeight).toBeLessThan(3000);
await waitFor(() => {
expect(ref.current?.getScrollState().scrollHeight).toBe(3);
});
unmount();
});
it('updates totalHeight correctly when estimated height differs from real height and scrolled up', async () => {
const ref = createRef<VirtualizedListRef<string>>();
const longData = Array.from({ length: 10 }, (_, i) => `Item ${i}`);
const itemHeight = 1;
const renderItem1px = ({ item }: { item: string }) => (
<Box height={itemHeight}>
<Text>{item}</Text>
</Box>
);
const keyExtractor = (item: string) => item;
const { unmount, waitUntilReady } = await render(
<Box height={5} width={100}>
<VirtualizedList
ref={ref}
data={longData}
renderItem={renderItem1px}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1000}
/>
</Box>,
);
for (let i = 1; i <= 10; i++) {
await act(async () => {
ref.current?.scrollTo(i * 1000);
});
await waitUntilReady();
}
await act(async () => {
ref.current?.scrollTo(0);
});
// Wait for the final scroll top to settle and height to be correct
await waitFor(() => {
expect(ref.current?.getScrollState().scrollTop).toBe(0);
expect(ref.current?.getScrollState().scrollHeight).toBe(10);
});
unmount();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { VirtualizedList } from './VirtualizedList.js';
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
import { Box, Text } from 'ink';
import { useState } from 'react';
import { describe, it, expect, vi } from 'vitest';
describe('VirtualizedList Interactivity', () => {
const keyExtractor = (item: { id: string }) => item.id;
const InteractiveItem = ({
id,
onToggle,
}: {
id: string;
onToggle: () => void;
}) => {
const { ref } = useVirtualizedListClick(id, 'toggle', onToggle);
return (
<Box height={1} width={80} ref={ref}>
<Text>Item {id}</Text>
</Box>
);
};
it('triggers callback when tagged area is clicked', async () => {
const onToggle = vi.fn();
const data = [{ id: '1' }];
const { simulateClick, waitUntilReady, lastFrame } =
await renderWithProviders(
<Box height={10} width={80}>
<VirtualizedList
data={data}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
renderItem={({ item }) => (
<InteractiveItem id={item.id} onToggle={onToggle} />
)}
/>
</Box>,
{ mouseEventsEnabled: true },
);
await waitUntilReady();
expect(lastFrame()).toContain('Item 1');
// Simulate click on the first line (Item 1)
// VirtualizedList is at (0,0) and Item 1 is at (0,0) relative to list.
// simulateClick expects absolute coordinates.
// In renderWithProviders, the wrapper Box is at (0,0)?
// Actually getBoundingBox(state.current.container) in VirtualizedList will give absolute coords.
await simulateClick(1, 1);
await waitFor(() => expect(onToggle).toHaveBeenCalled());
});
it('wakes up static item and triggers callback on click', async () => {
const onToggle = vi.fn();
const data = [{ id: '1' }];
const TestComponent = () => {
const [isStatic, setIsStatic] = useState(false);
return (
<Box height={10} width={80}>
<VirtualizedList
data={data}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
renderItem={({ item }) => (
<InteractiveItem id={item.id} onToggle={onToggle} />
)}
isStaticItem={() => isStatic}
/>
<Box
ref={(el) => {
if (el) {
setTimeout(() => setIsStatic(true), 100);
}
}}
/>
</Box>
);
};
const { simulateClick, waitUntilReady, lastFrame } =
await renderWithProviders(<TestComponent />, {
mouseEventsEnabled: true,
});
await waitUntilReady();
// Wait for the transition to static to happen and be recorded
await new Promise((r) => setTimeout(r, 200));
expect(lastFrame()).toContain('Item 1');
// Click to wake up and trigger
await simulateClick(1, 1);
await waitFor(() => expect(onToggle).toHaveBeenCalled());
});
});
@@ -7,41 +7,41 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│Item 1 │
│ │
│ │
│ │
│ │
│Item 2 │
│ │
│ │
│ │
│ │
│Item 3 │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 500) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│Item 500 │
│ │
│ │
│ │
│ ▄│
│ ▀│
│ │
│ │
│ │
│Item 501 │
│ │
│ │
│ ▄│
│ ▀│
│Item 502 │
│ │
│ │
│ │
│ │
│Item 503 │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -53,7 +53,7 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
│ │
│ │
│ │
Item 997
│ │
│ │
│ │