mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-14 05:17:18 -07:00
Checkpoint VirtualizedListClick
This commit is contained in:
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user