Checkpoint VirtualizedListClick

This commit is contained in:
jacob314
2026-05-14 13:20:05 -07:00
parent 8f7d79afa0
commit 9e76fa1276
19 changed files with 1101 additions and 143 deletions
@@ -345,10 +345,6 @@ function FixedVirtualizedList<T>(
prevScrollTop.current = rawDerivedActualScrollTop;
prevContainerHeight.current = scrollableContainerHeight;
const scrollTop = currentIsStickingToBottom
? Number.MAX_SAFE_INTEGER
: derivedActualScrollTop;
const startIndex = Math.max(
0,
Math.floor(derivedActualScrollTop / itemHeight) - 1,
@@ -362,16 +358,31 @@ function FixedVirtualizedList<T>(
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) {
const targetOffset = Math.max(
0,
derivedActualScrollTop - maxScrollbackLength,
);
const index = Math.floor(targetOffset / itemHeight);
return Math.max(0, index - 1);
// Render from the culled boundary.
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
return targetIndex;
}
return 0;
}
@@ -380,7 +391,10 @@ function FixedVirtualizedList<T>(
const renderRangeEnd = renderStatic ? maxEndIndex : endIndex;
const topSpacerHeight = renderRangeStart * itemHeight;
const topSpacerHeight = Math.max(
0,
renderRangeStart * itemHeight - culledHeight,
);
const bottomSpacerHeight = renderStatic
? 0
: totalHeight - (renderRangeEnd + 1) * itemHeight;
@@ -446,7 +460,11 @@ function FixedVirtualizedList<T>(
);
},
scrollTo: (offset: number) => {
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
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);
@@ -458,7 +476,7 @@ function FixedVirtualizedList<T>(
}
} else {
setIsStickingToBottom(false);
const newScrollTop = Math.max(0, offset);
const newScrollTop = Math.max(0, offset + culledHeight);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
}
@@ -530,10 +548,17 @@ function FixedVirtualizedList<T>(
},
getScrollIndex: () => scrollAnchor.index,
getScrollState: () => {
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
const effectiveTotalHeight = totalHeight - culledHeight;
const maxScroll = Math.max(
0,
effectiveTotalHeight - scrollableContainerHeight,
);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: totalHeight,
scrollTop: Math.min(
Math.max(0, getScrollTop() - culledHeight),
maxScroll,
),
scrollHeight: effectiveTotalHeight,
innerHeight: scrollableContainerHeight,
};
},
@@ -547,6 +572,7 @@ function FixedVirtualizedList<T>(
getScrollTop,
setPendingScrollTop,
itemHeight,
culledHeight,
],
);
@@ -554,7 +580,11 @@ function FixedVirtualizedList<T>(
<Box
overflowY="scroll"
overflowX="hidden"
scrollTop={scrollTop}
scrollTop={
isStickingToBottom
? Number.MAX_SAFE_INTEGER
: Math.max(0, getScrollTop() - culledHeight)
}
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
backgroundColor={props.backgroundColor}
width="100%"
@@ -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();
});
});
@@ -377,6 +377,46 @@ describe('<VirtualizedList />', () => {
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}>
<Text>{item}</Text>
</Box>
)}
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={99}
overflowToBackbuffer={true}
maxScrollbackLength={10}
/>
</Box>,
);
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('does not forget item heights when items are prepended', async () => {
const ref = createRef<VirtualizedListRef<string>>();
const data = ['Item 1', 'Item 2'];
@@ -27,6 +27,7 @@ import {
StaticRender,
getBoundingBox,
getScrollTop as getInkScrollTop,
useApp,
} from 'ink';
import {
useMouse,
@@ -38,6 +39,11 @@ import { debugLogger } from '@google/gemini-cli-core';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
export interface ClickableArea {
id: string;
box: { x: number; y: number; width: number; height: number };
}
export interface VirtualizedListContextValue {
registerInteractivity: (
itemKey: string,
@@ -47,6 +53,12 @@ export interface VirtualizedListContextValue {
getItemState: (itemKey: string, stateKey: string) => unknown;
isItemToggled: (itemKey: string) => boolean;
toggleItem: (itemKey: string) => void;
registerClickCallback: (
itemKey: string,
areaId: string,
callback: () => void,
) => void;
unregisterClickCallback: (itemKey: string, areaId: string) => void;
}
export const VirtualizedListContext =
@@ -108,6 +120,60 @@ function findLastIndex<T>(
return -1;
}
const extractClickableAreas = (rootNode: DOMElement): ClickableArea[] => {
const rootBox = getBoundingBox(rootNode);
const results: ClickableArea[] = [];
const traverse = (current: DOMElement) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const currentHack = current as unknown as {
attributes?: Record<string, unknown>;
yogaNode?: {
getComputedWidth: () => number;
getComputedHeight: () => number;
};
};
const attributes = currentHack.attributes;
const clickableId =
attributes?.['data-clickable'] ||
attributes?.['id'] ||
attributes?.['name'] ||
attributes?.['clickableId'] ||
attributes?.['nodeType'];
if (clickableId && typeof clickableId === 'string') {
const childBox = getBoundingBox(current);
// If getBoundingBox returns null/0 for dimensions, try to look at internal layout if available
const width = childBox.width ?? currentHack.yogaNode?.getComputedWidth();
const height =
childBox.height ?? currentHack.yogaNode?.getComputedHeight();
results.push({
id: clickableId,
box: {
x: (childBox.x ?? 0) - (rootBox.x ?? 0),
y: (childBox.y ?? 0) - (rootBox.y ?? 0),
width: width ?? 0,
height: height ?? 0,
},
});
}
for (const child of current.childNodes || []) {
if (child.nodeName && child.nodeName !== '#text') {
// Ensure it's a DOMElement
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
traverse(child as any as DOMElement);
}
}
};
traverse(rootNode);
return results;
};
const VirtualizedListItem = memo(
({
content,
@@ -128,7 +194,14 @@ const VirtualizedListItem = memo(
);
return (
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
<Box
width="100%"
flexDirection="column"
flexShrink={0}
ref={itemRef}
// @ts-expect-error custom attribute for testing
nodeType="item-root"
>
{content}
</Box>
);
@@ -169,9 +242,13 @@ function VirtualizedList<T>(
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
maxScrollbackLength,
maxScrollbackLength: maxScrollbackLengthProp,
} = props;
const app = useApp();
const maxScrollbackLength =
maxScrollbackLengthProp ?? app.options.maxScrollbackLength ?? 1000;
const [scrollAnchor, setScrollAnchor] = useState<{
index: number;
offset: number;
@@ -229,6 +306,8 @@ function VirtualizedList<T>(
);
const itemStates = useRef(new Map<string, Map<string, unknown>>());
const [toggledKeys, setToggledKeys] = useState(() => new Set<string>());
const toggledKeysRef = useRef(toggledKeys);
toggledKeysRef.current = toggledKeys;
const [temporarilyInteractiveIndexes, setTemporarilyInteractiveIndexes] =
useState(() => new Set<number>());
const renderedAsStatic = useRef<boolean[]>([]);
@@ -238,6 +317,11 @@ function VirtualizedList<T>(
event: MouseEvent;
} | null>(null);
const itemClickableAreas = useRef<Map<string, ClickableArea[]>>(new Map());
const clickCallbacks = useRef<Map<string, Map<string, () => void>>>(
new Map(),
);
const virtualizedListContextValue = useMemo<VirtualizedListContextValue>(
() => ({
registerInteractivity: (itemKey, options) => {
@@ -265,6 +349,23 @@ function VirtualizedList<T>(
return next;
});
},
registerClickCallback: (itemKey, areaId, callback) => {
let itemMap = clickCallbacks.current.get(itemKey);
if (!itemMap) {
itemMap = new Map();
clickCallbacks.current.set(itemKey, itemMap);
}
itemMap.set(areaId, callback);
},
unregisterClickCallback: (itemKey, areaId) => {
const itemMap = clickCallbacks.current.get(itemKey);
if (itemMap) {
itemMap.delete(areaId);
if (itemMap.size === 0) {
clickCallbacks.current.delete(itemKey);
}
}
},
}),
[toggledKeys],
);
@@ -285,7 +386,13 @@ function VirtualizedList<T>(
const onStaticRender = useCallback(
(index: number, key: string, node: DOMElement) => {
const height = Math.round(getBoundingBox(node).height);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const currentHack = node as unknown as {
yogaNode?: {
getComputedHeight: () => number;
};
};
const height = Math.round(currentHack.yogaNode?.getComputedHeight() ?? 0);
if (
height > 0 &&
(state.current.measuredHeights[index] !== height ||
@@ -295,6 +402,26 @@ function VirtualizedList<T>(
state.current.measuredKeys[index] = key;
setMeasurementVersion((v) => v + 1);
}
if (itemClickableAreas.current.has(key)) {
// If we already have areas for this item, don't re-extract.
// This is especially important for static items because children might
// have null dimensions in some environments (like tests) or might be
// cleared from the DOM after caching.
return;
}
const areas = extractClickableAreas(node);
if (areas.length > 0) {
// In some test environments, dimensions might be null/0.
// We only overwrite if we get valid dimensions or if we don't have areas yet.
const hasValidDimensions = areas.some(
(a) => a.box.width > 0 || a.box.height > 0,
);
if (hasValidDimensions || !itemClickableAreas.current.has(key)) {
itemClickableAreas.current.set(key, areas);
}
}
},
[],
);
@@ -324,6 +451,14 @@ function VirtualizedList<T>(
state.current.measuredKeys[index] = key;
changed = true;
}
if (height > 0) {
const areas = extractClickableAreas(entry.target);
if (areas.length > 0) {
itemClickableAreas.current.set(key, areas);
} else {
itemClickableAreas.current.delete(key);
}
}
}
}
if (changed) {
@@ -497,9 +632,38 @@ function VirtualizedList<T>(
estimatedItemHeight,
]);
const startIndex = Math.max(
0,
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
);
const viewHeightForEndIndex =
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
const endIndexOffset = offsets.findIndex(
(offset) => offset > actualScrollTop + viewHeightForEndIndex,
);
const endIndex =
endIndexOffset === -1
? data.length - 1
: Math.min(data.length - 1, endIndexOffset);
const culledHeight = useMemo(() => {
if (
overflowToBackbuffer &&
typeof maxScrollbackLength === 'number' &&
maxScrollbackLength > 0
) {
// Keep maxScrollbackLength items before the viewport to satisfy the backbuffer budget.
// We use items as a proxy for lines to be robust against estimation errors.
// We add 1 to startIndex to account for the 1-item overscan it includes.
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
return offsets[targetIndex] ?? 0;
}
return 0;
}, [overflowToBackbuffer, maxScrollbackLength, startIndex, offsets]);
const scrollTop = isStickingToBottom
? Number.MAX_SAFE_INTEGER
: actualScrollTop;
: actualScrollTop - culledHeight;
useLayoutEffect(() => {
if (state.current.prevDataLength === -1) {
@@ -660,20 +824,6 @@ function VirtualizedList<T>(
props.targetScrollIndex,
]);
const startIndex = Math.max(
0,
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
);
const viewHeightForEndIndex =
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
const endIndexOffset = offsets.findIndex(
(offset) => offset > actualScrollTop + viewHeightForEndIndex,
);
const endIndex =
endIndexOffset === -1
? data.length - 1
: Math.min(data.length - 1, endIndexOffset);
useEffect(() => {
setTemporarilyInteractiveIndexes((prev) => {
if (prev.size === 0) return prev;
@@ -692,25 +842,17 @@ function VirtualizedList<T>(
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);
// We render everything from the culledHeight boundary to ensure the
// backbuffer is fully populated.
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
return targetIndex;
}
return 0;
}
return startIndex;
}, [
overflowToBackbuffer,
maxScrollbackLength,
actualScrollTop,
offsets,
startIndex,
]);
}, [overflowToBackbuffer, maxScrollbackLength, startIndex]);
const topSpacerHeight = offsets[renderRangeStart];
const topSpacerHeight = Math.max(0, offsets[renderRangeStart] - culledHeight);
let renderRangeEnd = endIndex;
if (maxRenderRangeEnd.current > endIndex) {
@@ -735,8 +877,10 @@ function VirtualizedList<T>(
}
maxRenderRangeEnd.current = renderRangeEnd;
const bottomSpacerHeight =
totalHeight - (offsets[renderRangeEnd + 1] ?? totalHeight);
const bottomSpacerHeight = Math.max(
0,
totalHeight - (offsets[renderRangeEnd + 1] ?? totalHeight),
);
// 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.
@@ -744,10 +888,23 @@ function VirtualizedList<T>(
// 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 itemCache = useRef(
new Map<
string,
{
item: T;
element: React.ReactElement;
shouldBeStatic: boolean;
width: number | string | undefined;
containerWidth: number;
index: number;
isToggled: boolean;
}
>(),
);
const isReady =
containerHeight > 0 ||
process.env['NODE_ENV'] === 'test' ||
(width !== undefined && typeof width === 'number');
containerHeight > 0 || (width !== undefined && typeof width === 'number');
const renderedItems = useMemo(() => {
if (!isReady) {
@@ -768,31 +925,57 @@ function VirtualizedList<T>(
const shouldBeStatic = isStaticByDefault && !isTemporarilyInteractive;
renderedAsStatic.current[i] = shouldBeStatic;
const content = renderItem({ item, index: i });
const key = keyExtractor(item, i);
const cached = itemCache.current.get(key);
if (shouldBeStatic) {
items.push(
<StaticRender
key={`${key}-static-${typeof width === 'number' ? width : containerWidth}`}
width={typeof width === 'number' ? width : containerWidth}
onRender={(node: DOMElement) => onStaticRender(i, key, node)}
>
{() => content}
</StaticRender>,
);
const isToggled = toggledKeys.has(key);
let contentElement: React.ReactElement;
if (
cached &&
cached.item === item &&
cached.shouldBeStatic === shouldBeStatic &&
cached.width === width &&
cached.containerWidth === containerWidth &&
cached.index === i &&
cached.isToggled === isToggled
) {
contentElement = cached.element;
} else {
items.push(
<VirtualizedListItem
key={key}
itemKey={key}
content={content}
index={i}
onSetRef={onSetRef}
/>,
);
if (shouldBeStatic) {
contentElement = (
<StaticRender
key={`${key}-static-${typeof width === 'number' ? width : containerWidth}`}
width={typeof width === 'number' ? width : containerWidth}
onRender={(node: DOMElement) => onStaticRender(i, key, node)}
>
{() => renderItem({ item, index: i })}
</StaticRender>
);
} else {
contentElement = (
<VirtualizedListItem
key={key}
itemKey={key}
content={renderItem({ item, index: i })}
index={i}
onSetRef={onSetRef}
/>
);
}
itemCache.current.set(key, {
item,
element: contentElement,
shouldBeStatic,
width,
containerWidth,
index: i,
isToggled,
});
}
items.push(contentElement);
if (
!renderStatic &&
state.current.measuredKeys[i] !== key &&
@@ -811,6 +994,30 @@ function VirtualizedList<T>(
}
}
}
// Cleanup cache to avoid memory leaks
if (
itemCache.current.size >
Math.max(100, (renderRangeEnd - renderRangeStart + 1) * 3)
) {
const keysToKeep = new Set<string>();
for (
let i = Math.max(0, renderRangeStart - 50);
i <= Math.min(data.length - 1, renderRangeEnd + 50);
i++
) {
const item = data[i];
if (item) {
keysToKeep.add(keyExtractor(item, i));
}
}
for (const key of itemCache.current.keys()) {
if (!keysToKeep.has(key)) {
itemCache.current.delete(key);
}
}
}
return items;
}, [
isReady,
@@ -829,6 +1036,7 @@ function VirtualizedList<T>(
estimatedItemHeight,
temporarilyInteractiveIndexes,
onStaticRender,
toggledKeys,
]);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
@@ -873,31 +1081,88 @@ function VirtualizedList<T>(
) {
// getScrollTop() might return MAX_SAFE_INTEGER if stuck to bottom.
// We need the true rendered layout scroll top which ink exposes directly via getScrollTop.
const trueScrollTop = getInkScrollTop(state.current.container);
const trueScrollTop =
getInkScrollTop(state.current.container) + culledHeight;
const absoluteY = trueScrollTop + relativeY;
const index = findLastIndex(offsets, (offset) => offset <= absoluteY);
// DEBUG LOGGING
debugLogger.log(
`[Mouse] event=${event.name} index=${index} static=${renderedAsStatic.current[index]}`,
);
if (index !== -1) {
const item = data[index];
if (item) {
const itemKey = keyExtractor(item, index);
const options = interactiveKeys.current.get(itemKey);
// Determine if the click was exactly on the first line of the item
const itemStartY = offsets[index] ?? 0;
const isFirstLineClick = isClick && absoluteY === itemStartY;
// Hit-test against explicitly defined clickable areas inside the item
if (isClick && itemClickableAreas.current.has(itemKey)) {
const mouseRelativeY = absoluteY - itemStartY;
const areas = itemClickableAreas.current.get(itemKey) ?? [];
for (const area of areas) {
if (
relativeX >= area.box.x &&
relativeX < area.box.x + area.box.width &&
mouseRelativeY >= area.box.y &&
mouseRelativeY < area.box.y + area.box.height
) {
debugLogger.log(
`[Mouse] Clicked inside tagged area: ${area.id} in itemKey: ${itemKey}`,
);
if (renderedAsStatic.current[index]) {
debugLogger.log(
`[Mouse] Waking up static item index=${index} due to click on area=${area.id}`,
);
setTemporarilyInteractiveIndexes((prev) => {
const next = new Set(prev);
next.add(index);
return next;
});
setPendingReplayEvent({ index, event });
// Also toggle immediately if it was a first-line click
if (isFirstLineClick) {
virtualizedListContextValue.toggleItem(itemKey);
}
return;
}
const callback = clickCallbacks.current
.get(itemKey)
?.get(area.id);
if (callback) {
debugLogger.log(
`[Mouse] Dispatching click callback for area=${area.id} in itemKey=${itemKey}`,
);
callback();
return;
}
break;
}
}
}
debugLogger.log(
`[Mouse] itemKey=${itemKey} options=${JSON.stringify(options)}`,
);
if (options) {
// Determine if the click was exactly on the first line of the item
const itemStartY = offsets[index];
const isFirstLineClick = isClick && absoluteY === itemStartY;
if (isFirstLineClick && options.click) {
if (renderedAsStatic.current[index]) {
debugLogger.log(
`[Mouse] Waking up static item index=${index} due to first-line click`,
);
setTemporarilyInteractiveIndexes((prev) => {
const next = new Set(prev);
next.add(index);
return next;
});
setPendingReplayEvent({ index, event });
return;
}
debugLogger.log(
`[Mouse] First line click detected. Toggling itemKey=${itemKey}.`,
);
@@ -920,7 +1185,7 @@ function VirtualizedList<T>(
}
}
},
[offsets, data, keyExtractor, virtualizedListContextValue],
[offsets, data, keyExtractor, virtualizedListContextValue, culledHeight],
);
useMouse(handleMouse, { isActive: true });
@@ -951,7 +1216,11 @@ function VirtualizedList<T>(
);
},
scrollTo: (offset: number) => {
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
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);
@@ -963,7 +1232,7 @@ function VirtualizedList<T>(
}
} else {
setIsStickingToBottom(false);
const newScrollTop = Math.max(0, offset);
const newScrollTop = Math.max(0, offset + culledHeight);
setPendingScrollTop(newScrollTop);
setScrollAnchor(
getAnchorForScrollTop(
@@ -1058,10 +1327,17 @@ function VirtualizedList<T>(
},
getScrollIndex: () => scrollAnchor.index,
getScrollState: () => {
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
const effectiveTotalHeight = totalHeight - culledHeight;
const maxScroll = Math.max(
0,
effectiveTotalHeight - scrollableContainerHeight,
);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: totalHeight,
scrollTop: Math.min(
Math.max(0, getScrollTop() - culledHeight),
maxScroll,
),
scrollHeight: effectiveTotalHeight,
innerHeight: scrollableContainerHeight,
};
},
@@ -1075,6 +1351,7 @@ function VirtualizedList<T>(
scrollableContainerHeight,
getScrollTop,
setPendingScrollTop,
culledHeight,
],
);
@@ -1084,7 +1361,11 @@ function VirtualizedList<T>(
ref={containerRefCallback}
overflowY="scroll"
overflowX="hidden"
scrollTop={scrollTop}
scrollTop={
isStickingToBottom
? Number.MAX_SAFE_INTEGER
: Math.max(0, getScrollTop() - culledHeight)
}
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
backgroundColor={props.backgroundColor}
width="100%"
@@ -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());
});
});