Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 142 additions & 29 deletions packages/x/components/bubble/BubbleList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import omit from '@rc-component/util/lib/omit';
import pickAttrs from '@rc-component/util/lib/pickAttrs';
import VirtualList from '@rc-component/virtual-list';
import { clsx } from 'clsx';
import * as React from 'react';
import useProxyImperativeHandle from '../_util/hooks/use-proxy-imperative-handle';
Expand Down Expand Up @@ -133,6 +134,7 @@ const BubbleList: React.ForwardRefRenderFunction<BubbleListRef, BubbleListProps>
items,
autoScroll = true,
role,
virtual: virtualProp = false,
onScroll,
...restProps
} = props;
Expand All @@ -144,6 +146,25 @@ const BubbleList: React.ForwardRefRenderFunction<BubbleListRef, BubbleListProps>
// ============================= Refs =============================
const listRef = React.useRef<HTMLDivElement>(null);
const bubblesRef = React.useRef<BubblesRecord>({});
const virtualListRef = React.useRef<any>(null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ========================== Virtual Height =========================
const [virtualHeight, setVirtualHeight] = React.useState<number>(400);

React.useEffect(() => {
if (!virtualProp) return;
const updateHeight = () => {
if (listRef.current) {
setVirtualHeight(listRef.current.clientHeight);
}
};
updateHeight();
const observer = new ResizeObserver(updateHeight);
if (listRef.current) {
observer.observe(listRef.current);
}
return () => observer.disconnect();
}, [virtualProp]);

// ============================= States =============================
const [scrollBoxDom, setScrollBoxDom] = React.useState<HTMLDivElement | null>();
Expand Down Expand Up @@ -172,12 +193,57 @@ const BubbleList: React.ForwardRefRenderFunction<BubbleListRef, BubbleListProps>
...style,
};

// ========================== Merged Items ==========================
const mergedItems = React.useMemo(() => {
return items.map((item) => {
let mergedProps: BubbleItemType;
if (item.role && role) {
const cfg = role[item.role];
mergedProps = { ...(roleCfgIsFunction(cfg) ? cfg(item) : cfg), ...item };
} else {
mergedProps = item;
}
return mergedProps;
});
}, [items, role]);

// ==================== Virtual Auto Scroll =========================
const prevItemsLengthRef = React.useRef(mergedItems.length);

React.useEffect(() => {
if (!virtualProp || !autoScroll) return;
const prevLen = prevItemsLengthRef.current;
prevItemsLengthRef.current = mergedItems.length;
// Only auto scroll when items are added (not removed)
if (mergedItems.length > prevLen && virtualListRef.current) {
requestAnimationFrame(() => {
virtualListRef.current?.scrollTo({ index: mergedItems.length - 1 });
});
}
}, [mergedItems, virtualProp, autoScroll]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ============================= Refs =============================
useProxyImperativeHandle<HTMLDivElement, BubbleListRef>(ref, () => {
return {
nativeElement: listRef.current!,
scrollBoxNativeElement: scrollBoxDom!,
scrollTo: ({ key, top, behavior = 'smooth', block }) => {
// Virtual mode: use VirtualList's scrollTo
if (virtualProp && virtualListRef.current) {
if (typeof top === 'number') {
virtualListRef.current.scrollTo({ top, behavior });
} else if (top === 'bottom') {
virtualListRef.current.scrollTo({ index: mergedItems.length - 1, behavior });
} else if (top === 'top') {
virtualListRef.current.scrollTo({ top: 0, behavior });
} else if (key) {
const itemIndex = mergedItems.findIndex((item) => item.key === key);
if (itemIndex >= 0) {
virtualListRef.current.scrollTo({ index: itemIndex, behavior, block });
}
}
return;
}
const { scrollHeight, clientHeight } = scrollBoxDom!;
if (typeof top === 'number') {
scrollTo({
Expand All @@ -200,43 +266,90 @@ const BubbleList: React.ForwardRefRenderFunction<BubbleListRef, BubbleListProps>
};
});

// ========================= Virtual Render =========================
const renderItem = (item: BubbleItemType) => (
<div key={item.key}>
<BubbleListItem
classNames={omit(classNames, ['root', 'scroll'])}
styles={omit(styles, ['root', 'scroll'])}
{...omit(item, ['key'])}
key={item.key}
_key={item.key}
bubblesRef={bubblesRef}
/>
</div>
);

// ============================ Render ============================
return (
<div {...domProps} className={mergedClassNames} style={mergedStyle} ref={listRef}>
<div
className={clsx(`${listPrefixCls}-scroll-box`, classNames.scroll, {
[`${listPrefixCls}-autoscroll`]: autoScroll,
})}
style={styles.scroll}
ref={(node) => setScrollBoxDom(node)}
onScroll={onScroll}
>
{/* 映射 scrollHeight 到 dom.height,以使用 ResizeObserver 来监听高度变化 */}
{virtualProp ? (
<div
ref={(node) => setScrollContentDom(node)}
className={clsx(`${listPrefixCls}-scroll-content`)}
className={clsx(`${listPrefixCls}-scroll-box`, classNames.scroll, {
[`${listPrefixCls}-autoscroll`]: autoScroll,
})}
style={{ ...styles.scroll, overflow: 'hidden' }}
ref={(node) => setScrollBoxDom(node)}
>
{items.map((item) => {
let mergedProps: BubbleItemType;
if (item.role && role) {
const cfg = role[item.role];
mergedProps = { ...(roleCfgIsFunction(cfg) ? cfg(item) : cfg), ...item };
} else {
mergedProps = item;
<style>{`
.${listPrefixCls}-scroll-box .rc-virtual-list-scrollbar { display: none !important; }
.${listPrefixCls}-scroll-box .rc-virtual-list-holder {
overflow-y: auto !important;
}
return (
<BubbleListItem
classNames={omit(classNames, ['root', 'scroll'])}
styles={omit(styles, ['root', 'scroll'])}
{...omit(mergedProps, ['key'])}
key={item.key}
_key={item.key}
bubblesRef={bubblesRef}
/>
);
`}</style>
Comment on lines +324 to +329

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Injecting raw <style> tags directly inside the component's render method is an anti-pattern in React and Ant Design. It can cause SSR hydration mismatches, violate Content Security Policy (CSP) rules regarding inline styles, and bypass the CSS-in-JS theme token system.\n\nThese styles should be moved to the CSS-in-JS style generator file (useBubbleListStyle).

<VirtualList<BubbleItemType>
ref={virtualListRef}
data={mergedItems}
height={virtualHeight}
itemHeight={64}
itemKey={(item) => item.key}
virtual
onScroll={onScroll}
style={{
display: 'flex',
flexDirection: 'column',
...(autoScroll ? { flexDirection: 'column-reverse' } : {}),
}}
>
{(item) => renderItem(item)}
</VirtualList>
</div>
) : (
<div
className={clsx(`${listPrefixCls}-scroll-box`, classNames.scroll, {
[`${listPrefixCls}-autoscroll`]: autoScroll,
})}
style={styles.scroll}
ref={(node) => setScrollBoxDom(node)}
onScroll={onScroll}
>
{/* 映射 scrollHeight 到 dom.height,以使用 ResizeObserver 来监听高度变化 */}
<div
ref={(node) => setScrollContentDom(node)}
className={clsx(`${listPrefixCls}-scroll-content`)}
>
{items.map((item) => {
let mergedProps: BubbleItemType;
if (item.role && role) {
const cfg = role[item.role];
mergedProps = { ...(roleCfgIsFunction(cfg) ? cfg(item) : cfg), ...item };
} else {
mergedProps = item;
}
return (
<BubbleListItem
classNames={omit(classNames, ['root', 'scroll'])}
styles={omit(styles, ['root', 'scroll'])}
{...omit(mergedProps, ['key'])}
key={item.key}
_key={item.key}
bubblesRef={bubblesRef}
/>
);
})}
</div>
</div>
</div>
)}
</div>
);
};
Expand Down
113 changes: 113 additions & 0 deletions packages/x/components/bubble/__tests__/list-benchmark.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { render } from '@testing-library/react';
import React from 'react';
import BubbleList from '../BubbleList';
import type { BubbleItemType } from '../interface';

// Mock @rc-component/virtual-list
jest.mock('@rc-component/virtual-list', () => {
const React = require('react');
const MockVirtualList = React.forwardRef((props: any, ref: any) => {
React.useImperativeHandle(ref, () => ({
scrollTo: jest.fn(),
nativeElement: null,
}));
const { data, children, itemKey, virtual, itemHeight, height, ...rest } = props;
return (
<div data-testid="mock-virtual-list" {...rest}>
{data?.map((item: any, index: number) => (
<div key={itemKey ? itemKey(item) : index}>{children(item, index)}</div>
))}
</div>
);
});
return { __esModule: true, default: MockVirtualList };
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

describe('Bubble.List benchmark - 1000+ items', () => {
const generateLargeItems = (count: number): BubbleItemType[] =>
Array.from({ length: count }, (_, i) => ({
key: `item-${i}`,
role: i % 3 === 0 ? 'ai' : i % 3 === 1 ? 'user' : 'system',
content: `Message content ${i} - ${'lorem ipsum '.repeat(i % 10)}`,
}));

it('should render 1000 items correctly in virtual mode', () => {
const items = generateLargeItems(1000);
const { container } = render(
<BubbleList items={items} virtual style={{ height: 600 }} />,
);

const bubbles = container.querySelectorAll('.ant-bubble');
expect(bubbles).toHaveLength(1000);
expect(container).toHaveTextContent('Message content 0');
expect(container).toHaveTextContent('Message content 999');
});

it('should render 1000 items correctly in non-virtual mode', () => {
const items = generateLargeItems(1000);
const { container } = render(
<BubbleList items={items} style={{ height: 600 }} />,
);

const bubbles = container.querySelectorAll('.ant-bubble');
expect(bubbles).toHaveLength(1000);
});

it('should render 2000+ items in virtual mode', () => {
const items = generateLargeItems(2000);
const { container } = render(
<BubbleList items={items} virtual style={{ height: 600 }} />,
);

const bubbles = container.querySelectorAll('.ant-bubble');
expect(bubbles).toHaveLength(2000);
expect(container).toHaveTextContent('Message content 1999');
});

it('should handle mixed role types in large data', () => {
const items: BubbleItemType[] = Array.from({ length: 1000 }, (_, i) => {
if (i % 100 === 0) return { key: `d-${i}`, role: 'divider', content: `Divider ${i}` };
if (i % 50 === 0) return { key: `s-${i}`, role: 'system', content: `System ${i}` };
return { key: `m-${i}`, role: i % 2 === 0 ? 'ai' : 'user', content: `Msg ${i}` };
});

const { container } = render(
<BubbleList items={items} virtual autoScroll={false} style={{ height: 600 }} />,
);

const dividers = container.querySelectorAll('.ant-bubble-divider');
const systems = container.querySelectorAll('.ant-bubble-system');
const bubbles = container.querySelectorAll('.ant-bubble');

expect(bubbles.length).toBe(1000);
expect(dividers.length).toBe(10);
expect(systems.length).toBe(10);
});

it('should correctly update when items change in virtual mode', () => {
const initialItems = generateLargeItems(500);
const { container, rerender } = render(
<BubbleList items={initialItems} virtual style={{ height: 600 }} />,
);

expect(container.querySelectorAll('.ant-bubble')).toHaveLength(500);

const updatedItems = generateLargeItems(1000);
rerender(<BubbleList items={updatedItems} virtual style={{ height: 600 }} />);

expect(container.querySelectorAll('.ant-bubble')).toHaveLength(1000);
expect(container).toHaveTextContent('Message content 999');
});

it('should measure render time for 1000 items', () => {
const items = generateLargeItems(1000);

const start = performance.now();
render(<BubbleList items={items} virtual style={{ height: 600 }} />);
const end = performance.now();

// Virtual mode should render reasonably fast even with 1000 items
// (In mock mode all items render, but the test verifies correctness)
expect(end - start).toBeLessThan(5000);
});
});
Loading