Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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
187 changes: 158 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,73 @@ 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;
// 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.

// Auto scroll when content grows (e.g. streaming messages getting longer)
React.useEffect(() => {
if (!virtualProp || !autoScroll || !listRef.current) return;
const observer = new ResizeObserver(() => {
if (virtualListRef.current) {
requestAnimationFrame(() => {
virtualListRef.current?.scrollTo({ index: mergedItems.length - 1 });
});
}
});
observer.observe(listRef.current);
return () => observer.disconnect();
}, [virtualProp, autoScroll, mergedItems]);
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) {
// Map block to VirtualList's align option
const align = block === 'center' ? 'center' : block === 'end' ? 'end' : 'start';
if (typeof top === 'number') {
virtualListRef.current.scrollTo({ top, behavior });
} else if (top === 'bottom') {
virtualListRef.current.scrollTo({ index: mergedItems.length - 1, align, behavior });
} else if (top === 'top') {
virtualListRef.current.scrollTo({ top: 0, behavior });
} else if (key != null) {
const itemIndex = mergedItems.findIndex((item) => item.key === key);
if (itemIndex >= 0) {
virtualListRef.current.scrollTo({ index: itemIndex, align, behavior });
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return;
}
const { scrollHeight, clientHeight } = scrollBoxDom!;
if (typeof top === 'number') {
scrollTo({
Expand All @@ -200,43 +282,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
75 changes: 75 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,75 @@
import { render } from '@testing-library/react';
import React from 'react';
import BubbleList from '../BubbleList';
import type { BubbleItemType } from '../interface';

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

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

// Should render without crash
expect(container.querySelector('.ant-bubble-list')).toBeInTheDocument();
});

it('should render 1000 items in non-virtual mode (all DOM nodes)', () => {
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 without crash', () => {
const items = generateLargeItems(2000);
const { container } = render(<BubbleList items={items} virtual style={{ height: 600 }} />);

expect(container.querySelector('.ant-bubble-list')).toBeInTheDocument();
});

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 }} />,
);

expect(container.querySelector('.ant-bubble-list')).toBeInTheDocument();
});

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.querySelector('.ant-bubble-list')).toBeInTheDocument();

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

expect(container.querySelector('.ant-bubble-list')).toBeInTheDocument();
});

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

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

// Should render without crash in reasonable time
expect(end - start).toBeLessThan(10000);
});
});
Loading