fix(cli): ensure dialogs stay scrolled to bottom in alternate buffer mode (#20527)

This commit is contained in:
Jacob Richman
2026-02-27 08:00:07 -08:00
committed by GitHub
parent d7320f5425
commit 14dd07be00
6 changed files with 571 additions and 192 deletions

View File

@@ -6,20 +6,11 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { Scrollable } from './Scrollable.js';
import { Text } from 'ink';
import { Text, Box } from 'ink';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as ScrollProviderModule from '../../contexts/ScrollProvider.js';
import { act } from 'react';
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
getInnerHeight: vi.fn(() => 5),
getScrollHeight: vi.fn(() => 10),
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })),
};
});
import { waitFor } from '../../../test-utils/async.js';
vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({
useAnimatedScrollbar: (
@@ -129,20 +120,26 @@ describe('<Scrollable />', () => {
</Scrollable>,
);
await waitUntilReady2();
expect(capturedEntry.getScrollState().scrollTop).toBe(5);
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(5);
});
// Call scrollBy multiple times (upwards) in the same tick
await act(async () => {
capturedEntry!.scrollBy(-1);
capturedEntry!.scrollBy(-1);
capturedEntry?.scrollBy(-1);
capturedEntry?.scrollBy(-1);
});
// Should have moved up by 2 (5 -> 3)
expect(capturedEntry.getScrollState().scrollTop).toBe(3);
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(3);
});
await act(async () => {
capturedEntry!.scrollBy(-2);
capturedEntry?.scrollBy(-2);
});
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(1);
});
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
unmount2();
});
@@ -191,10 +188,6 @@ describe('<Scrollable />', () => {
keySequence,
expectedScrollTop,
}) => {
// Dynamically import ink to mock getScrollHeight
const ink = await import('ink');
vi.mocked(ink.getScrollHeight).mockReturnValue(scrollHeight);
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
async (entry, isActive) => {
@@ -206,7 +199,9 @@ describe('<Scrollable />', () => {
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<Scrollable hasFocus={true} height={5}>
<Text>Content</Text>
<Box height={scrollHeight}>
<Text>Content</Text>
</Box>
</Scrollable>,
);
await waitUntilReady();

View File

@@ -4,15 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, {
useState,
useEffect,
useRef,
useLayoutEffect,
useCallback,
useMemo,
} from 'react';
import { Box, getInnerHeight, getScrollHeight, type DOMElement } from 'ink';
import type React from 'react';
import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
import { Box, ResizeObserver, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
@@ -41,62 +35,101 @@ export const Scrollable: React.FC<ScrollableProps> = ({
flexGrow,
}) => {
const [scrollTop, setScrollTop] = useState(0);
const ref = useRef<DOMElement>(null);
const viewportRef = useRef<DOMElement | null>(null);
const contentRef = useRef<DOMElement | null>(null);
const [size, setSize] = useState({
innerHeight: 0,
innerHeight: typeof height === 'number' ? height : 0,
scrollHeight: 0,
});
const sizeRef = useRef(size);
useEffect(() => {
const scrollTopRef = useRef(scrollTop);
useLayoutEffect(() => {
sizeRef.current = size;
}, [size]);
const childrenCountRef = useRef(0);
// This effect needs to run on every render to correctly measure the container
// and scroll to the bottom if new children are added.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (!ref.current) {
return;
scrollTopRef.current = scrollTop;
}, [scrollTop]);
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const contentObserverRef = useRef<ResizeObserver | null>(null);
const viewportRefCallback = useCallback((node: DOMElement | null) => {
viewportObserverRef.current?.disconnect();
viewportRef.current = node;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const innerHeight = Math.round(entry.contentRect.height);
setSize((prev) => {
const scrollHeight = prev.scrollHeight;
const isAtBottom =
scrollHeight > prev.innerHeight &&
scrollTopRef.current >= scrollHeight - prev.innerHeight - 1;
if (isAtBottom) {
setScrollTop(Number.MAX_SAFE_INTEGER);
}
return { ...prev, innerHeight };
});
}
});
observer.observe(node);
viewportObserverRef.current = observer;
}
const innerHeight = Math.round(getInnerHeight(ref.current));
const scrollHeight = Math.round(getScrollHeight(ref.current));
}, []);
const isAtBottom =
scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1;
const contentRefCallback = useCallback(
(node: DOMElement | null) => {
contentObserverRef.current?.disconnect();
contentRef.current = node;
if (
size.innerHeight !== innerHeight ||
size.scrollHeight !== scrollHeight
) {
setSize({ innerHeight, scrollHeight });
if (isAtBottom) {
setScrollTop(Math.max(0, scrollHeight - innerHeight));
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const scrollHeight = Math.round(entry.contentRect.height);
setSize((prev) => {
const innerHeight = prev.innerHeight;
const isAtBottom =
prev.scrollHeight > innerHeight &&
scrollTopRef.current >= prev.scrollHeight - innerHeight - 1;
if (
isAtBottom ||
(scrollToBottom && scrollHeight > prev.scrollHeight)
) {
setScrollTop(Number.MAX_SAFE_INTEGER);
}
return { ...prev, scrollHeight };
});
}
});
observer.observe(node);
contentObserverRef.current = observer;
}
}
const childCountCurrent = React.Children.count(children);
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
setScrollTop(Math.max(0, scrollHeight - innerHeight));
}
childrenCountRef.current = childCountCurrent;
});
},
[scrollToBottom],
);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
const scrollBy = useCallback(
(delta: number) => {
const { scrollHeight, innerHeight } = sizeRef.current;
const current = getScrollTop();
const next = Math.min(
Math.max(0, current + delta),
Math.max(0, scrollHeight - innerHeight),
);
const maxScroll = Math.max(0, scrollHeight - innerHeight);
const current = Math.min(getScrollTop(), maxScroll);
let next = Math.max(0, current + delta);
if (next >= maxScroll) {
next = Number.MAX_SAFE_INTEGER;
}
setPendingScrollTop(next);
setScrollTop(next);
},
[sizeRef, getScrollTop, setPendingScrollTop],
[getScrollTop, setPendingScrollTop],
);
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
@@ -107,10 +140,11 @@ export const Scrollable: React.FC<ScrollableProps> = ({
const { scrollHeight, innerHeight } = sizeRef.current;
const scrollTop = getScrollTop();
const maxScroll = Math.max(0, scrollHeight - innerHeight);
const actualScrollTop = Math.min(scrollTop, maxScroll);
// Only capture scroll-up events if there's room;
// otherwise allow events to bubble.
if (scrollTop > 0) {
if (actualScrollTop > 0) {
if (keyMatchers[Command.PAGE_UP](key)) {
scrollByWithAnimation(-innerHeight);
return true;
@@ -123,7 +157,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
// Only capture scroll-down events if there's room;
// otherwise allow events to bubble.
if (scrollTop < maxScroll) {
if (actualScrollTop < maxScroll) {
if (keyMatchers[Command.PAGE_DOWN](key)) {
scrollByWithAnimation(innerHeight);
return true;
@@ -140,21 +174,21 @@ export const Scrollable: React.FC<ScrollableProps> = ({
{ isActive: hasFocus },
);
const getScrollState = useCallback(
() => ({
scrollTop: getScrollTop(),
const getScrollState = useCallback(() => {
const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: size.scrollHeight,
innerHeight: size.innerHeight,
}),
[getScrollTop, size.scrollHeight, size.innerHeight],
);
};
}, [getScrollTop, size.scrollHeight, size.innerHeight]);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
const scrollableEntry = useMemo(
() => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ref: ref as React.RefObject<DOMElement>,
ref: viewportRef as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
hasFocus: hasFocusCallback,
@@ -167,7 +201,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
return (
<Box
ref={ref}
ref={viewportRefCallback}
maxHeight={maxHeight}
width={width ?? maxWidth}
height={height}
@@ -183,7 +217,12 @@ export const Scrollable: React.FC<ScrollableProps> = ({
based on the children's content. It also adds a right padding to
make room for the scrollbar.
*/}
<Box flexShrink={0} paddingRight={1} flexDirection="column">
<Box
ref={contentRefCallback}
flexShrink={0}
paddingRight={1}
flexDirection="column"
>
{children}
</Box>
</Box>

View File

@@ -479,4 +479,277 @@ describe('ScrollableList Demo Behavior', () => {
});
});
});
it('regression: remove last item and add 2 items when scrolled to bottom', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
null;
const TestComp = () => {
const [items, setItems] = useState<Item[]>(
Array.from({ length: 10 }, (_, i) => ({
id: String(i),
title: `Item ${i}`,
})),
);
useEffect(() => {
setItemsFunc = setItems;
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={5}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Scrolled to bottom, max scroll = 10 - 5 = 5
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(5);
});
// Remove last element and add 2 elements
await act(async () => {
setItemsFunc!((prev) => {
const next = prev.slice(0, prev.length - 1);
next.push({ id: '10', title: 'Item 10' });
next.push({ id: '11', title: 'Item 11' });
return next;
});
});
await result!.waitUntilReady();
// Auto scrolls to new bottom: max scroll = 11 - 5 = 6
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(6);
});
// Scroll up slightly
await act(async () => {
listRef?.scrollBy(-2);
});
await result!.waitUntilReady();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(4);
});
// Scroll back to bottom
await act(async () => {
listRef?.scrollToEnd();
});
await result!.waitUntilReady();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(6);
});
// Add two more elements
await act(async () => {
setItemsFunc!((prev) => [
...prev,
{ id: '12', title: 'Item 12' },
{ id: '13', title: 'Item 13' },
]);
});
await result!.waitUntilReady();
// Auto scrolls to bottom: max scroll = 13 - 5 = 8
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(8);
});
result!.unmount();
});
it('regression: bottom-most element changes size but list does not update', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let expandLastFunc: (() => void) | null = null;
const ItemWithState = ({
item,
isLast,
}: {
item: Item;
isLast: boolean;
}) => {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (isLast) {
expandLastFunc = () => setExpanded(true);
}
}, [isLast]);
return (
<Box flexDirection="column">
<Text>{item.title}</Text>
{expanded && <Text>Expanded content</Text>}
</Box>
);
};
const TestComp = () => {
// items array is stable
const [items] = useState(() =>
Array.from({ length: 5 }, (_, i) => ({
id: String(i),
title: `Item ${i}`,
})),
);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={4}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item, index }) => (
<ItemWithState item={item} isLast={index === 4} />
)}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Initially, total height is 5. viewport is 4. scroll is 1.
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(1);
});
// Expand the last item locally, without re-rendering the list!
await act(async () => {
expandLastFunc!();
});
await result!.waitUntilReady();
// The total height becomes 6. It should remain scrolled to bottom, so scroll becomes 2.
// This is expected to FAIL currently because VirtualizedList won't remeasure
// unless data changes or container height changes.
await waitFor(
() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(2);
},
{ timeout: 1000 },
);
result!.unmount();
});
it('regression: prepending items does not corrupt heights (total height correct)', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
null;
const TestComp = () => {
// Items 1 to 5. Item 1 is very tall.
const [items, setItems] = useState<Item[]>(
Array.from({ length: 5 }, (_, i) => ({
id: String(i + 1),
title: `Item ${i + 1}`,
})),
);
useEffect(() => {
setItemsFunc = setItems;
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => (
<Box height={item.id === '1' ? 10 : 2}>
<Text>{item.title}</Text>
</Box>
)}
estimatedItemHeight={() => 2}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Scroll is at bottom.
// Heights: Item 1: 10, Item 2: 2, Item 3: 2, Item 4: 2, Item 5: 2.
// Total height = 18. Container = 10. Max scroll = 8.
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(8);
});
// Prepend an item!
await act(async () => {
setItemsFunc!((prev) => [{ id: '0', title: 'Item 0' }, ...prev]);
});
await result!.waitUntilReady();
// Now items: 0(2), 1(10), 2(2), 3(2), 4(2), 5(2).
// Total height = 20. Container = 10. Max scroll = 10.
// Auto-scrolls to bottom because it was sticking!
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(10);
});
result!.unmount();
});
});

View File

@@ -10,7 +10,7 @@ import {
useImperativeHandle,
useCallback,
useMemo,
useEffect,
useLayoutEffect,
} from 'react';
import type React from 'react';
import {
@@ -105,7 +105,7 @@ function ScrollableList<T>(
smoothScrollState.current.active = false;
}, []);
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
useLayoutEffect(() => stopSmoothScroll, [stopSmoothScroll]);
const smoothScrollTo = useCallback(
(
@@ -120,15 +120,19 @@ function ScrollableList<T>(
innerHeight: 0,
};
const {
scrollTop: startScrollTop,
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) {
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
effectiveTarget = maxScrollTop;
}
@@ -138,8 +142,11 @@ function ScrollableList<T>(
);
if (duration === 0) {
if (targetScrollTop === SCROLL_TO_ITEM_END) {
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
} else {
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
}
@@ -168,8 +175,11 @@ function ScrollableList<T>(
ease;
if (progress >= 1) {
if (targetScrollTop === SCROLL_TO_ITEM_END) {
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
} else {
virtualizedListRef.current?.scrollTo(Math.round(current));
}
@@ -200,9 +210,13 @@ function ScrollableList<T>(
) {
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
: scrollState.scrollTop;
: Math.min(scrollState.scrollTop, maxScroll);
const innerHeight = scrollState.innerHeight;
smoothScrollTo(current + direction * innerHeight);
return true;

View File

@@ -5,6 +5,7 @@
*/
import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
import { Text, Box } from 'ink';
import {
@@ -275,9 +276,11 @@ describe('<VirtualizedList />', () => {
await waitUntilReady();
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 9');
await waitFor(() => {
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 9');
});
unmount();
});

View File

@@ -10,7 +10,6 @@ import {
useLayoutEffect,
forwardRef,
useImperativeHandle,
useEffect,
useMemo,
useCallback,
} from 'react';
@@ -19,7 +18,7 @@ import { theme } from '../../semantic-colors.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { type DOMElement, measureElement, Box } from 'ink';
import { type DOMElement, Box, ResizeObserver } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
@@ -81,7 +80,7 @@ function VirtualizedList<T>(
} = props;
const { copyModeEnabled } = useUIState();
const dataRef = useRef(data);
useEffect(() => {
useLayoutEffect(() => {
dataRef.current = data;
}, [data]);
@@ -108,6 +107,7 @@ function VirtualizedList<T>(
return { index: 0, offset: 0 };
});
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
@@ -116,73 +116,75 @@ function VirtualizedList<T>(
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
return scrollToEnd;
});
const containerRef = useRef<DOMElement>(null);
const containerRef = useRef<DOMElement | null>(null);
const [containerHeight, setContainerHeight] = useState(0);
const itemRefs = useRef<Array<DOMElement | null>>([]);
const [heights, setHeights] = useState<number[]>([]);
const [heights, setHeights] = useState<Record<string, number>>({});
const isInitialScrollSet = useRef(false);
const containerObserverRef = useRef<ResizeObserver | null>(null);
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
const containerRefCallback = useCallback((node: DOMElement | null) => {
containerObserverRef.current?.disconnect();
containerRef.current = node;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerHeight(Math.round(entry.contentRect.height));
}
});
observer.observe(node);
containerObserverRef.current = observer;
}
}, []);
const itemsObserver = useMemo(
() =>
new ResizeObserver((entries) => {
setHeights((prev) => {
let next: Record<string, number> | null = null;
for (const entry of entries) {
const key = nodeToKeyRef.current.get(entry.target);
if (key !== undefined) {
const height = Math.round(entry.contentRect.height);
if (prev[key] !== height) {
if (!next) {
next = { ...prev };
}
next[key] = height;
}
}
}
return next ?? prev;
});
}),
[],
);
useLayoutEffect(
() => () => {
containerObserverRef.current?.disconnect();
itemsObserver.disconnect();
},
[itemsObserver],
);
const { totalHeight, offsets } = useMemo(() => {
const offsets: number[] = [0];
let totalHeight = 0;
for (let i = 0; i < data.length; i++) {
const height = heights[i] ?? estimatedItemHeight(i);
const key = keyExtractor(data[i], i);
const height = heights[key] ?? estimatedItemHeight(i);
totalHeight += height;
offsets.push(totalHeight);
}
return { totalHeight, offsets };
}, [heights, data, estimatedItemHeight]);
}, [heights, data, estimatedItemHeight, keyExtractor]);
useEffect(() => {
setHeights((prevHeights) => {
if (data.length === prevHeights.length) {
return prevHeights;
}
const newHeights = [...prevHeights];
if (data.length < prevHeights.length) {
newHeights.length = data.length;
} else {
for (let i = prevHeights.length; i < data.length; i++) {
newHeights[i] = estimatedItemHeight(i);
}
}
return newHeights;
});
}, [data, estimatedItemHeight]);
// This layout effect needs to run on every render to correctly measure the
// container and ensure we recompute the layout if it has changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (containerRef.current) {
const height = Math.round(measureElement(containerRef.current).height);
if (containerHeight !== height) {
setContainerHeight(height);
}
}
let newHeights: number[] | null = null;
for (let i = startIndex; i <= endIndex; i++) {
const itemRef = itemRefs.current[i];
if (itemRef) {
const height = Math.round(measureElement(itemRef).height);
if (height !== heights[i]) {
if (!newHeights) {
newHeights = [...heights];
}
newHeights[i] = height;
}
}
}
if (newHeights) {
setHeights(newHeights);
}
});
const scrollableContainerHeight = containerRef.current
? Math.round(measureElement(containerRef.current).height)
: containerHeight;
const scrollableContainerHeight = containerHeight;
const getAnchorForScrollTop = useCallback(
(
@@ -199,23 +201,36 @@ function VirtualizedList<T>(
[],
);
const scrollTop = useMemo(() => {
const actualScrollTop = useMemo(() => {
const offset = offsets[scrollAnchor.index];
if (typeof offset !== 'number') {
return 0;
}
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
const itemHeight = heights[scrollAnchor.index] ?? 0;
const item = data[scrollAnchor.index];
const key = item ? keyExtractor(item, scrollAnchor.index) : '';
const itemHeight = heights[key] ?? 0;
return offset + itemHeight - scrollableContainerHeight;
}
return offset + scrollAnchor.offset;
}, [scrollAnchor, offsets, heights, scrollableContainerHeight]);
}, [
scrollAnchor,
offsets,
heights,
scrollableContainerHeight,
data,
keyExtractor,
]);
const scrollTop = isStickingToBottom
? Number.MAX_SAFE_INTEGER
: actualScrollTop;
const prevDataLength = useRef(data.length);
const prevTotalHeight = useRef(totalHeight);
const prevScrollTop = useRef(scrollTop);
const prevScrollTop = useRef(actualScrollTop);
const prevContainerHeight = useRef(scrollableContainerHeight);
useLayoutEffect(() => {
@@ -226,9 +241,7 @@ function VirtualizedList<T>(
prevTotalHeight.current - prevContainerHeight.current - 1;
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
// If the user was at the bottom, they are now sticking. This handles
// manually scrolling back to the bottom.
if (wasAtBottom && scrollTop >= prevScrollTop.current) {
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
setIsStickingToBottom(true);
}
@@ -236,9 +249,6 @@ function VirtualizedList<T>(
const containerChanged =
prevContainerHeight.current !== scrollableContainerHeight;
// We scroll to the end if:
// 1. The list grew AND we were already at the bottom (or sticking).
// 2. We are sticking to the bottom AND the container size changed.
if (
(listGrew && (isStickingToBottom || wasAtBottom)) ||
(isStickingToBottom && containerChanged)
@@ -247,34 +257,28 @@ function VirtualizedList<T>(
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
});
// If we are scrolling to the bottom, we are by definition sticking.
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
}
// Scenario 2: The list has changed (shrunk) in a way that our
// current scroll position or anchor is invalid. We should adjust to the bottom.
else if (
} else if (
(scrollAnchor.index >= data.length ||
scrollTop > totalHeight - scrollableContainerHeight) &&
actualScrollTop > totalHeight - scrollableContainerHeight) &&
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
} else if (data.length === 0) {
// List is now empty, reset scroll to top.
setScrollAnchor({ index: 0, offset: 0 });
}
// Update refs for the next render cycle.
prevDataLength.current = data.length;
prevTotalHeight.current = totalHeight;
prevScrollTop.current = scrollTop;
prevScrollTop.current = actualScrollTop;
prevContainerHeight.current = scrollableContainerHeight;
}, [
data.length,
totalHeight,
scrollTop,
actualScrollTop,
scrollableContainerHeight,
scrollAnchor.index,
getAnchorForScrollTop,
@@ -334,10 +338,10 @@ function VirtualizedList<T>(
const startIndex = Math.max(
0,
findLastIndex(offsets, (offset) => offset <= scrollTop) - 1,
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
);
const endIndexOffset = offsets.findIndex(
(offset) => offset > scrollTop + scrollableContainerHeight,
(offset) => offset > actualScrollTop + scrollableContainerHeight,
);
const endIndex =
endIndexOffset === -1
@@ -348,6 +352,32 @@ function VirtualizedList<T>(
const bottomSpacerHeight =
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
// Maintain a stable set of observed nodes using useLayoutEffect
const observedNodes = useRef<Set<DOMElement>>(new Set());
useLayoutEffect(() => {
const currentNodes = new Set<DOMElement>();
for (let i = startIndex; i <= endIndex; i++) {
const node = itemRefs.current[i];
const item = data[i];
if (node && item) {
currentNodes.add(node);
const key = keyExtractor(item, i);
// Always update the key mapping because React can reuse nodes at different indices/keys
nodeToKeyRef.current.set(node, key);
if (!observedNodes.current.has(node)) {
itemsObserver.observe(node);
}
}
}
for (const node of observedNodes.current) {
if (!currentNodes.has(node)) {
itemsObserver.unobserve(node);
nodeToKeyRef.current.delete(node);
}
}
observedNodes.current = currentNodes;
});
const renderedItems = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = data[i];
@@ -356,6 +386,8 @@ function VirtualizedList<T>(
<Box
key={keyExtractor(item, i)}
width="100%"
flexDirection="column"
flexShrink={0}
ref={(el) => {
itemRefs.current[i] = el;
}}
@@ -376,27 +408,39 @@ function VirtualizedList<T>(
setIsStickingToBottom(false);
}
const currentScrollTop = getScrollTop();
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
currentScrollTop + delta,
),
);
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(newScrollTop, offsets));
setScrollAnchor(
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets),
);
},
scrollTo: (offset: number) => {
setIsStickingToBottom(false);
const newScrollTop = Math.max(
0,
Math.min(totalHeight - scrollableContainerHeight, offset),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
});
}
} else {
setIsStickingToBottom(false);
const newScrollTop = Math.max(0, offset);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
}
},
scrollToEnd: () => {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
@@ -416,10 +460,14 @@ function VirtualizedList<T>(
setIsStickingToBottom(false);
const offset = offsets[index];
if (offset !== undefined) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
maxScroll,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
@@ -441,10 +489,14 @@ function VirtualizedList<T>(
if (index !== -1) {
const offset = offsets[index];
if (offset !== undefined) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
maxScroll,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
@@ -454,11 +506,14 @@ function VirtualizedList<T>(
}
},
getScrollIndex: () => scrollAnchor.index,
getScrollState: () => ({
scrollTop: getScrollTop(),
scrollHeight: totalHeight,
innerHeight: containerHeight,
}),
getScrollState: () => {
const maxScroll = Math.max(0, totalHeight - containerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: totalHeight,
innerHeight: containerHeight,
};
},
}),
[
offsets,
@@ -475,7 +530,7 @@ function VirtualizedList<T>(
return (
<Box
ref={containerRef}
ref={containerRefCallback}
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
overflowX="hidden"
scrollTop={copyModeEnabled ? 0 : scrollTop}
@@ -489,7 +544,7 @@ function VirtualizedList<T>(
flexShrink={0}
width="100%"
flexDirection="column"
marginTop={copyModeEnabled ? -scrollTop : 0}
marginTop={copyModeEnabled ? -actualScrollTop : 0}
>
<Box height={topSpacerHeight} flexShrink={0} />
{renderedItems}