mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
fix(cli): ensure dialogs stay scrolled to bottom in alternate buffer mode (#20527)
This commit is contained in:
@@ -6,20 +6,11 @@
|
|||||||
|
|
||||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||||
import { Scrollable } from './Scrollable.js';
|
import { Scrollable } from './Scrollable.js';
|
||||||
import { Text } from 'ink';
|
import { Text, Box } from 'ink';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import * as ScrollProviderModule from '../../contexts/ScrollProvider.js';
|
import * as ScrollProviderModule from '../../contexts/ScrollProvider.js';
|
||||||
import { act } from 'react';
|
import { act } from 'react';
|
||||||
|
import { waitFor } from '../../../test-utils/async.js';
|
||||||
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 })),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({
|
vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({
|
||||||
useAnimatedScrollbar: (
|
useAnimatedScrollbar: (
|
||||||
@@ -129,20 +120,26 @@ describe('<Scrollable />', () => {
|
|||||||
</Scrollable>,
|
</Scrollable>,
|
||||||
);
|
);
|
||||||
await waitUntilReady2();
|
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
|
// Call scrollBy multiple times (upwards) in the same tick
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
capturedEntry!.scrollBy(-1);
|
capturedEntry?.scrollBy(-1);
|
||||||
capturedEntry!.scrollBy(-1);
|
capturedEntry?.scrollBy(-1);
|
||||||
});
|
});
|
||||||
// Should have moved up by 2 (5 -> 3)
|
// 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 () => {
|
await act(async () => {
|
||||||
capturedEntry!.scrollBy(-2);
|
capturedEntry?.scrollBy(-2);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(capturedEntry?.getScrollState().scrollTop).toBe(1);
|
||||||
});
|
});
|
||||||
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
|
|
||||||
unmount2();
|
unmount2();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,10 +188,6 @@ describe('<Scrollable />', () => {
|
|||||||
keySequence,
|
keySequence,
|
||||||
expectedScrollTop,
|
expectedScrollTop,
|
||||||
}) => {
|
}) => {
|
||||||
// Dynamically import ink to mock getScrollHeight
|
|
||||||
const ink = await import('ink');
|
|
||||||
vi.mocked(ink.getScrollHeight).mockReturnValue(scrollHeight);
|
|
||||||
|
|
||||||
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
|
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
|
||||||
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
|
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
|
||||||
async (entry, isActive) => {
|
async (entry, isActive) => {
|
||||||
@@ -206,7 +199,9 @@ describe('<Scrollable />', () => {
|
|||||||
|
|
||||||
const { stdin, waitUntilReady, unmount } = renderWithProviders(
|
const { stdin, waitUntilReady, unmount } = renderWithProviders(
|
||||||
<Scrollable hasFocus={true} height={5}>
|
<Scrollable hasFocus={true} height={5}>
|
||||||
|
<Box height={scrollHeight}>
|
||||||
<Text>Content</Text>
|
<Text>Content</Text>
|
||||||
|
</Box>
|
||||||
</Scrollable>,
|
</Scrollable>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
|
|||||||
@@ -4,15 +4,9 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {
|
import type React from 'react';
|
||||||
useState,
|
import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
|
||||||
useEffect,
|
import { Box, ResizeObserver, type DOMElement } from 'ink';
|
||||||
useRef,
|
|
||||||
useLayoutEffect,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
} from 'react';
|
|
||||||
import { Box, getInnerHeight, getScrollHeight, type DOMElement } from 'ink';
|
|
||||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||||
@@ -41,62 +35,101 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
|||||||
flexGrow,
|
flexGrow,
|
||||||
}) => {
|
}) => {
|
||||||
const [scrollTop, setScrollTop] = useState(0);
|
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({
|
const [size, setSize] = useState({
|
||||||
innerHeight: 0,
|
innerHeight: typeof height === 'number' ? height : 0,
|
||||||
scrollHeight: 0,
|
scrollHeight: 0,
|
||||||
});
|
});
|
||||||
const sizeRef = useRef(size);
|
const sizeRef = useRef(size);
|
||||||
useEffect(() => {
|
const scrollTopRef = useRef(scrollTop);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
sizeRef.current = size;
|
sizeRef.current = size;
|
||||||
}, [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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!ref.current) {
|
scrollTopRef.current = scrollTop;
|
||||||
return;
|
}, [scrollTop]);
|
||||||
}
|
|
||||||
const innerHeight = Math.round(getInnerHeight(ref.current));
|
|
||||||
const scrollHeight = Math.round(getScrollHeight(ref.current));
|
|
||||||
|
|
||||||
|
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 =
|
const isAtBottom =
|
||||||
scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1;
|
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 contentRefCallback = useCallback(
|
||||||
|
(node: DOMElement | null) => {
|
||||||
|
contentObserverRef.current?.disconnect();
|
||||||
|
contentRef.current = node;
|
||||||
|
|
||||||
|
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 (
|
if (
|
||||||
size.innerHeight !== innerHeight ||
|
isAtBottom ||
|
||||||
size.scrollHeight !== scrollHeight
|
(scrollToBottom && scrollHeight > prev.scrollHeight)
|
||||||
) {
|
) {
|
||||||
setSize({ innerHeight, scrollHeight });
|
setScrollTop(Number.MAX_SAFE_INTEGER);
|
||||||
if (isAtBottom) {
|
|
||||||
setScrollTop(Math.max(0, scrollHeight - innerHeight));
|
|
||||||
}
|
}
|
||||||
}
|
return { ...prev, scrollHeight };
|
||||||
|
|
||||||
const childCountCurrent = React.Children.count(children);
|
|
||||||
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
|
|
||||||
setScrollTop(Math.max(0, scrollHeight - innerHeight));
|
|
||||||
}
|
|
||||||
childrenCountRef.current = childCountCurrent;
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(node);
|
||||||
|
contentObserverRef.current = observer;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollToBottom],
|
||||||
|
);
|
||||||
|
|
||||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||||
|
|
||||||
const scrollBy = useCallback(
|
const scrollBy = useCallback(
|
||||||
(delta: number) => {
|
(delta: number) => {
|
||||||
const { scrollHeight, innerHeight } = sizeRef.current;
|
const { scrollHeight, innerHeight } = sizeRef.current;
|
||||||
const current = getScrollTop();
|
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
||||||
const next = Math.min(
|
const current = Math.min(getScrollTop(), maxScroll);
|
||||||
Math.max(0, current + delta),
|
let next = Math.max(0, current + delta);
|
||||||
Math.max(0, scrollHeight - innerHeight),
|
if (next >= maxScroll) {
|
||||||
);
|
next = Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
setPendingScrollTop(next);
|
setPendingScrollTop(next);
|
||||||
setScrollTop(next);
|
setScrollTop(next);
|
||||||
},
|
},
|
||||||
[sizeRef, getScrollTop, setPendingScrollTop],
|
[getScrollTop, setPendingScrollTop],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
||||||
@@ -107,10 +140,11 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
|||||||
const { scrollHeight, innerHeight } = sizeRef.current;
|
const { scrollHeight, innerHeight } = sizeRef.current;
|
||||||
const scrollTop = getScrollTop();
|
const scrollTop = getScrollTop();
|
||||||
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
||||||
|
const actualScrollTop = Math.min(scrollTop, maxScroll);
|
||||||
|
|
||||||
// Only capture scroll-up events if there's room;
|
// Only capture scroll-up events if there's room;
|
||||||
// otherwise allow events to bubble.
|
// otherwise allow events to bubble.
|
||||||
if (scrollTop > 0) {
|
if (actualScrollTop > 0) {
|
||||||
if (keyMatchers[Command.PAGE_UP](key)) {
|
if (keyMatchers[Command.PAGE_UP](key)) {
|
||||||
scrollByWithAnimation(-innerHeight);
|
scrollByWithAnimation(-innerHeight);
|
||||||
return true;
|
return true;
|
||||||
@@ -123,7 +157,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
|||||||
|
|
||||||
// Only capture scroll-down events if there's room;
|
// Only capture scroll-down events if there's room;
|
||||||
// otherwise allow events to bubble.
|
// otherwise allow events to bubble.
|
||||||
if (scrollTop < maxScroll) {
|
if (actualScrollTop < maxScroll) {
|
||||||
if (keyMatchers[Command.PAGE_DOWN](key)) {
|
if (keyMatchers[Command.PAGE_DOWN](key)) {
|
||||||
scrollByWithAnimation(innerHeight);
|
scrollByWithAnimation(innerHeight);
|
||||||
return true;
|
return true;
|
||||||
@@ -140,21 +174,21 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
|||||||
{ isActive: hasFocus },
|
{ isActive: hasFocus },
|
||||||
);
|
);
|
||||||
|
|
||||||
const getScrollState = useCallback(
|
const getScrollState = useCallback(() => {
|
||||||
() => ({
|
const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight);
|
||||||
scrollTop: getScrollTop(),
|
return {
|
||||||
|
scrollTop: Math.min(getScrollTop(), maxScroll),
|
||||||
scrollHeight: size.scrollHeight,
|
scrollHeight: size.scrollHeight,
|
||||||
innerHeight: size.innerHeight,
|
innerHeight: size.innerHeight,
|
||||||
}),
|
};
|
||||||
[getScrollTop, size.scrollHeight, size.innerHeight],
|
}, [getScrollTop, size.scrollHeight, size.innerHeight]);
|
||||||
);
|
|
||||||
|
|
||||||
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
||||||
|
|
||||||
const scrollableEntry = useMemo(
|
const scrollableEntry = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
ref: ref as React.RefObject<DOMElement>,
|
ref: viewportRef as React.RefObject<DOMElement>,
|
||||||
getScrollState,
|
getScrollState,
|
||||||
scrollBy: scrollByWithAnimation,
|
scrollBy: scrollByWithAnimation,
|
||||||
hasFocus: hasFocusCallback,
|
hasFocus: hasFocusCallback,
|
||||||
@@ -167,7 +201,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={ref}
|
ref={viewportRefCallback}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
width={width ?? maxWidth}
|
width={width ?? maxWidth}
|
||||||
height={height}
|
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
|
based on the children's content. It also adds a right padding to
|
||||||
make room for the scrollbar.
|
make room for the scrollbar.
|
||||||
*/}
|
*/}
|
||||||
<Box flexShrink={0} paddingRight={1} flexDirection="column">
|
<Box
|
||||||
|
ref={contentRefCallback}
|
||||||
|
flexShrink={0}
|
||||||
|
paddingRight={1}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
useEffect,
|
useLayoutEffect,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -105,7 +105,7 @@ function ScrollableList<T>(
|
|||||||
smoothScrollState.current.active = false;
|
smoothScrollState.current.active = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
|
useLayoutEffect(() => stopSmoothScroll, [stopSmoothScroll]);
|
||||||
|
|
||||||
const smoothScrollTo = useCallback(
|
const smoothScrollTo = useCallback(
|
||||||
(
|
(
|
||||||
@@ -120,15 +120,19 @@ function ScrollableList<T>(
|
|||||||
innerHeight: 0,
|
innerHeight: 0,
|
||||||
};
|
};
|
||||||
const {
|
const {
|
||||||
scrollTop: startScrollTop,
|
scrollTop: rawStartScrollTop,
|
||||||
scrollHeight,
|
scrollHeight,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
} = scrollState;
|
} = scrollState;
|
||||||
|
|
||||||
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
|
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
|
||||||
|
const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop);
|
||||||
|
|
||||||
let effectiveTarget = targetScrollTop;
|
let effectiveTarget = targetScrollTop;
|
||||||
if (targetScrollTop === SCROLL_TO_ITEM_END) {
|
if (
|
||||||
|
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||||
|
targetScrollTop >= maxScrollTop
|
||||||
|
) {
|
||||||
effectiveTarget = maxScrollTop;
|
effectiveTarget = maxScrollTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +142,11 @@ function ScrollableList<T>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (duration === 0) {
|
if (duration === 0) {
|
||||||
if (targetScrollTop === SCROLL_TO_ITEM_END) {
|
if (
|
||||||
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
|
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||||
|
targetScrollTop >= maxScrollTop
|
||||||
|
) {
|
||||||
|
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
|
||||||
} else {
|
} else {
|
||||||
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
|
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
|
||||||
}
|
}
|
||||||
@@ -168,8 +175,11 @@ function ScrollableList<T>(
|
|||||||
ease;
|
ease;
|
||||||
|
|
||||||
if (progress >= 1) {
|
if (progress >= 1) {
|
||||||
if (targetScrollTop === SCROLL_TO_ITEM_END) {
|
if (
|
||||||
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
|
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||||
|
targetScrollTop >= maxScrollTop
|
||||||
|
) {
|
||||||
|
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
|
||||||
} else {
|
} else {
|
||||||
virtualizedListRef.current?.scrollTo(Math.round(current));
|
virtualizedListRef.current?.scrollTo(Math.round(current));
|
||||||
}
|
}
|
||||||
@@ -200,9 +210,13 @@ function ScrollableList<T>(
|
|||||||
) {
|
) {
|
||||||
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
|
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
|
||||||
const scrollState = getScrollState();
|
const scrollState = getScrollState();
|
||||||
|
const maxScroll = Math.max(
|
||||||
|
0,
|
||||||
|
scrollState.scrollHeight - scrollState.innerHeight,
|
||||||
|
);
|
||||||
const current = smoothScrollState.current.active
|
const current = smoothScrollState.current.active
|
||||||
? smoothScrollState.current.to
|
? smoothScrollState.current.to
|
||||||
: scrollState.scrollTop;
|
: Math.min(scrollState.scrollTop, maxScroll);
|
||||||
const innerHeight = scrollState.innerHeight;
|
const innerHeight = scrollState.innerHeight;
|
||||||
smoothScrollTo(current + direction * innerHeight);
|
smoothScrollTo(current + direction * innerHeight);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render } from '../../../test-utils/render.js';
|
import { render } from '../../../test-utils/render.js';
|
||||||
|
import { waitFor } from '../../../test-utils/async.js';
|
||||||
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
|
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
|
||||||
import { Text, Box } from 'ink';
|
import { Text, Box } from 'ink';
|
||||||
import {
|
import {
|
||||||
@@ -275,9 +276,11 @@ describe('<VirtualizedList />', () => {
|
|||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
|
|
||||||
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
|
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
|
||||||
|
await waitFor(() => {
|
||||||
expect(lastFrame()).toContain('Item 0');
|
expect(lastFrame()).toContain('Item 0');
|
||||||
expect(lastFrame()).toContain('Item 1');
|
expect(lastFrame()).toContain('Item 1');
|
||||||
expect(lastFrame()).toContain('Item 9');
|
expect(lastFrame()).toContain('Item 9');
|
||||||
|
});
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useCallback,
|
useCallback,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -19,7 +18,7 @@ import { theme } from '../../semantic-colors.js';
|
|||||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||||
import { useUIState } from '../../contexts/UIStateContext.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;
|
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
@@ -81,7 +80,7 @@ function VirtualizedList<T>(
|
|||||||
} = props;
|
} = props;
|
||||||
const { copyModeEnabled } = useUIState();
|
const { copyModeEnabled } = useUIState();
|
||||||
const dataRef = useRef(data);
|
const dataRef = useRef(data);
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
dataRef.current = data;
|
dataRef.current = data;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -108,6 +107,7 @@ function VirtualizedList<T>(
|
|||||||
|
|
||||||
return { index: 0, offset: 0 };
|
return { index: 0, offset: 0 };
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
|
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
|
||||||
const scrollToEnd =
|
const scrollToEnd =
|
||||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||||
@@ -116,73 +116,75 @@ function VirtualizedList<T>(
|
|||||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||||
return scrollToEnd;
|
return scrollToEnd;
|
||||||
});
|
});
|
||||||
const containerRef = useRef<DOMElement>(null);
|
|
||||||
|
const containerRef = useRef<DOMElement | null>(null);
|
||||||
const [containerHeight, setContainerHeight] = useState(0);
|
const [containerHeight, setContainerHeight] = useState(0);
|
||||||
const itemRefs = useRef<Array<DOMElement | null>>([]);
|
const itemRefs = useRef<Array<DOMElement | null>>([]);
|
||||||
const [heights, setHeights] = useState<number[]>([]);
|
const [heights, setHeights] = useState<Record<string, number>>({});
|
||||||
const isInitialScrollSet = useRef(false);
|
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 { totalHeight, offsets } = useMemo(() => {
|
||||||
const offsets: number[] = [0];
|
const offsets: number[] = [0];
|
||||||
let totalHeight = 0;
|
let totalHeight = 0;
|
||||||
for (let i = 0; i < data.length; i++) {
|
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;
|
totalHeight += height;
|
||||||
offsets.push(totalHeight);
|
offsets.push(totalHeight);
|
||||||
}
|
}
|
||||||
return { totalHeight, offsets };
|
return { totalHeight, offsets };
|
||||||
}, [heights, data, estimatedItemHeight]);
|
}, [heights, data, estimatedItemHeight, keyExtractor]);
|
||||||
|
|
||||||
useEffect(() => {
|
const scrollableContainerHeight = containerHeight;
|
||||||
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 getAnchorForScrollTop = useCallback(
|
const getAnchorForScrollTop = useCallback(
|
||||||
(
|
(
|
||||||
@@ -199,23 +201,36 @@ function VirtualizedList<T>(
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const scrollTop = useMemo(() => {
|
const actualScrollTop = useMemo(() => {
|
||||||
const offset = offsets[scrollAnchor.index];
|
const offset = offsets[scrollAnchor.index];
|
||||||
if (typeof offset !== 'number') {
|
if (typeof offset !== 'number') {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
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 + itemHeight - scrollableContainerHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
return offset + scrollAnchor.offset;
|
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 prevDataLength = useRef(data.length);
|
||||||
const prevTotalHeight = useRef(totalHeight);
|
const prevTotalHeight = useRef(totalHeight);
|
||||||
const prevScrollTop = useRef(scrollTop);
|
const prevScrollTop = useRef(actualScrollTop);
|
||||||
const prevContainerHeight = useRef(scrollableContainerHeight);
|
const prevContainerHeight = useRef(scrollableContainerHeight);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -226,9 +241,7 @@ function VirtualizedList<T>(
|
|||||||
prevTotalHeight.current - prevContainerHeight.current - 1;
|
prevTotalHeight.current - prevContainerHeight.current - 1;
|
||||||
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
||||||
|
|
||||||
// If the user was at the bottom, they are now sticking. This handles
|
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
|
||||||
// manually scrolling back to the bottom.
|
|
||||||
if (wasAtBottom && scrollTop >= prevScrollTop.current) {
|
|
||||||
setIsStickingToBottom(true);
|
setIsStickingToBottom(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,9 +249,6 @@ function VirtualizedList<T>(
|
|||||||
const containerChanged =
|
const containerChanged =
|
||||||
prevContainerHeight.current !== scrollableContainerHeight;
|
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 (
|
if (
|
||||||
(listGrew && (isStickingToBottom || wasAtBottom)) ||
|
(listGrew && (isStickingToBottom || wasAtBottom)) ||
|
||||||
(isStickingToBottom && containerChanged)
|
(isStickingToBottom && containerChanged)
|
||||||
@@ -247,34 +257,28 @@ function VirtualizedList<T>(
|
|||||||
index: data.length > 0 ? data.length - 1 : 0,
|
index: data.length > 0 ? data.length - 1 : 0,
|
||||||
offset: SCROLL_TO_ITEM_END,
|
offset: SCROLL_TO_ITEM_END,
|
||||||
});
|
});
|
||||||
// If we are scrolling to the bottom, we are by definition sticking.
|
|
||||||
if (!isStickingToBottom) {
|
if (!isStickingToBottom) {
|
||||||
setIsStickingToBottom(true);
|
setIsStickingToBottom(true);
|
||||||
}
|
}
|
||||||
}
|
} else if (
|
||||||
// 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 (
|
|
||||||
(scrollAnchor.index >= data.length ||
|
(scrollAnchor.index >= data.length ||
|
||||||
scrollTop > totalHeight - scrollableContainerHeight) &&
|
actualScrollTop > totalHeight - scrollableContainerHeight) &&
|
||||||
data.length > 0
|
data.length > 0
|
||||||
) {
|
) {
|
||||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||||
} else if (data.length === 0) {
|
} else if (data.length === 0) {
|
||||||
// List is now empty, reset scroll to top.
|
|
||||||
setScrollAnchor({ index: 0, offset: 0 });
|
setScrollAnchor({ index: 0, offset: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update refs for the next render cycle.
|
|
||||||
prevDataLength.current = data.length;
|
prevDataLength.current = data.length;
|
||||||
prevTotalHeight.current = totalHeight;
|
prevTotalHeight.current = totalHeight;
|
||||||
prevScrollTop.current = scrollTop;
|
prevScrollTop.current = actualScrollTop;
|
||||||
prevContainerHeight.current = scrollableContainerHeight;
|
prevContainerHeight.current = scrollableContainerHeight;
|
||||||
}, [
|
}, [
|
||||||
data.length,
|
data.length,
|
||||||
totalHeight,
|
totalHeight,
|
||||||
scrollTop,
|
actualScrollTop,
|
||||||
scrollableContainerHeight,
|
scrollableContainerHeight,
|
||||||
scrollAnchor.index,
|
scrollAnchor.index,
|
||||||
getAnchorForScrollTop,
|
getAnchorForScrollTop,
|
||||||
@@ -334,10 +338,10 @@ function VirtualizedList<T>(
|
|||||||
|
|
||||||
const startIndex = Math.max(
|
const startIndex = Math.max(
|
||||||
0,
|
0,
|
||||||
findLastIndex(offsets, (offset) => offset <= scrollTop) - 1,
|
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
|
||||||
);
|
);
|
||||||
const endIndexOffset = offsets.findIndex(
|
const endIndexOffset = offsets.findIndex(
|
||||||
(offset) => offset > scrollTop + scrollableContainerHeight,
|
(offset) => offset > actualScrollTop + scrollableContainerHeight,
|
||||||
);
|
);
|
||||||
const endIndex =
|
const endIndex =
|
||||||
endIndexOffset === -1
|
endIndexOffset === -1
|
||||||
@@ -348,6 +352,32 @@ function VirtualizedList<T>(
|
|||||||
const bottomSpacerHeight =
|
const bottomSpacerHeight =
|
||||||
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
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 = [];
|
const renderedItems = [];
|
||||||
for (let i = startIndex; i <= endIndex; i++) {
|
for (let i = startIndex; i <= endIndex; i++) {
|
||||||
const item = data[i];
|
const item = data[i];
|
||||||
@@ -356,6 +386,8 @@ function VirtualizedList<T>(
|
|||||||
<Box
|
<Box
|
||||||
key={keyExtractor(item, i)}
|
key={keyExtractor(item, i)}
|
||||||
width="100%"
|
width="100%"
|
||||||
|
flexDirection="column"
|
||||||
|
flexShrink={0}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
itemRefs.current[i] = el;
|
itemRefs.current[i] = el;
|
||||||
}}
|
}}
|
||||||
@@ -376,27 +408,39 @@ function VirtualizedList<T>(
|
|||||||
setIsStickingToBottom(false);
|
setIsStickingToBottom(false);
|
||||||
}
|
}
|
||||||
const currentScrollTop = getScrollTop();
|
const currentScrollTop = getScrollTop();
|
||||||
const newScrollTop = Math.max(
|
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||||
0,
|
const actualCurrent = Math.min(currentScrollTop, maxScroll);
|
||||||
Math.min(
|
let newScrollTop = Math.max(0, actualCurrent + delta);
|
||||||
totalHeight - scrollableContainerHeight,
|
if (newScrollTop >= maxScroll) {
|
||||||
currentScrollTop + delta,
|
setIsStickingToBottom(true);
|
||||||
),
|
newScrollTop = Number.MAX_SAFE_INTEGER;
|
||||||
);
|
}
|
||||||
setPendingScrollTop(newScrollTop);
|
setPendingScrollTop(newScrollTop);
|
||||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
setScrollAnchor(
|
||||||
|
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
scrollTo: (offset: number) => {
|
scrollTo: (offset: number) => {
|
||||||
|
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);
|
setIsStickingToBottom(false);
|
||||||
const newScrollTop = Math.max(
|
const newScrollTop = Math.max(0, offset);
|
||||||
0,
|
|
||||||
Math.min(totalHeight - scrollableContainerHeight, offset),
|
|
||||||
);
|
|
||||||
setPendingScrollTop(newScrollTop);
|
setPendingScrollTop(newScrollTop);
|
||||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
scrollToEnd: () => {
|
scrollToEnd: () => {
|
||||||
setIsStickingToBottom(true);
|
setIsStickingToBottom(true);
|
||||||
|
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
setScrollAnchor({
|
setScrollAnchor({
|
||||||
index: data.length - 1,
|
index: data.length - 1,
|
||||||
@@ -416,10 +460,14 @@ function VirtualizedList<T>(
|
|||||||
setIsStickingToBottom(false);
|
setIsStickingToBottom(false);
|
||||||
const offset = offsets[index];
|
const offset = offsets[index];
|
||||||
if (offset !== undefined) {
|
if (offset !== undefined) {
|
||||||
|
const maxScroll = Math.max(
|
||||||
|
0,
|
||||||
|
totalHeight - scrollableContainerHeight,
|
||||||
|
);
|
||||||
const newScrollTop = Math.max(
|
const newScrollTop = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
totalHeight - scrollableContainerHeight,
|
maxScroll,
|
||||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -441,10 +489,14 @@ function VirtualizedList<T>(
|
|||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const offset = offsets[index];
|
const offset = offsets[index];
|
||||||
if (offset !== undefined) {
|
if (offset !== undefined) {
|
||||||
|
const maxScroll = Math.max(
|
||||||
|
0,
|
||||||
|
totalHeight - scrollableContainerHeight,
|
||||||
|
);
|
||||||
const newScrollTop = Math.max(
|
const newScrollTop = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
totalHeight - scrollableContainerHeight,
|
maxScroll,
|
||||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -454,11 +506,14 @@ function VirtualizedList<T>(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getScrollIndex: () => scrollAnchor.index,
|
getScrollIndex: () => scrollAnchor.index,
|
||||||
getScrollState: () => ({
|
getScrollState: () => {
|
||||||
scrollTop: getScrollTop(),
|
const maxScroll = Math.max(0, totalHeight - containerHeight);
|
||||||
|
return {
|
||||||
|
scrollTop: Math.min(getScrollTop(), maxScroll),
|
||||||
scrollHeight: totalHeight,
|
scrollHeight: totalHeight,
|
||||||
innerHeight: containerHeight,
|
innerHeight: containerHeight,
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
offsets,
|
offsets,
|
||||||
@@ -475,7 +530,7 @@ function VirtualizedList<T>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={containerRef}
|
ref={containerRefCallback}
|
||||||
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
|
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
|
||||||
overflowX="hidden"
|
overflowX="hidden"
|
||||||
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
||||||
@@ -489,7 +544,7 @@ function VirtualizedList<T>(
|
|||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
width="100%"
|
width="100%"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
marginTop={copyModeEnabled ? -scrollTop : 0}
|
marginTop={copyModeEnabled ? -actualScrollTop : 0}
|
||||||
>
|
>
|
||||||
<Box height={topSpacerHeight} flexShrink={0} />
|
<Box height={topSpacerHeight} flexShrink={0} />
|
||||||
{renderedItems}
|
{renderedItems}
|
||||||
|
|||||||
Reference in New Issue
Block a user