mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 fix: fix auto scroll (#11734)
* fix auto scroll * fix auto scroll * Update DebugInspector.tsx
This commit is contained in:
@@ -40,7 +40,7 @@ const DebugInspector = memo(() => {
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.9)',
|
||||
borderRadius: 8,
|
||||
bottom: 80,
|
||||
bottom: 135,
|
||||
display: 'flex',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
|
||||
@@ -8,9 +8,14 @@ import {
|
||||
useConversationStore,
|
||||
virtuaListSelectors,
|
||||
} from '../../../store';
|
||||
import BackBottom from '../BackBottom';
|
||||
import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from './DebugInspector';
|
||||
|
||||
/**
|
||||
* AutoScroll component - handles auto-scrolling logic during AI generation.
|
||||
* Should be placed inside the last item of VList so it only triggers when visible.
|
||||
*
|
||||
* This component has no visual output - it only contains the auto-scroll logic.
|
||||
* Debug UI and BackBottom button are rendered separately outside VList.
|
||||
*/
|
||||
const AutoScroll = memo(() => {
|
||||
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
|
||||
const isScrolling = useConversationStore(virtuaListSelectors.isScrolling);
|
||||
@@ -31,54 +36,8 @@ const AutoScroll = memo(() => {
|
||||
}
|
||||
}, [shouldAutoScroll, scrollToBottom, dbMessages.length, lastMessageContentLength]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
{OPEN_DEV_INSPECTOR && (
|
||||
<>
|
||||
{/* Threshold 区域顶部边界线 */}
|
||||
<div
|
||||
style={{
|
||||
background: atBottom ? '#22c55e' : '#ef4444',
|
||||
height: 2,
|
||||
left: 0,
|
||||
opacity: 0.5,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: -AT_BOTTOM_THRESHOLD,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Threshold 区域 mask - 显示在指示线上方 */}
|
||||
<div
|
||||
style={{
|
||||
background: atBottom
|
||||
? 'linear-gradient(to top, rgba(34, 197, 94, 0.15), transparent)'
|
||||
: 'linear-gradient(to top, rgba(239, 68, 68, 0.1), transparent)',
|
||||
height: AT_BOTTOM_THRESHOLD,
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: -AT_BOTTOM_THRESHOLD,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* AutoScroll 位置指示线(底部) */}
|
||||
<div
|
||||
style={{
|
||||
background: atBottom ? '#22c55e' : '#ef4444',
|
||||
height: 2,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<BackBottom onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />
|
||||
</div>
|
||||
);
|
||||
// No visual output - this component only handles auto-scroll logic
|
||||
return null;
|
||||
});
|
||||
|
||||
AutoScroll.displayName = 'ConversationAutoScroll';
|
||||
|
||||
@@ -4,30 +4,83 @@ import { ArrowDownIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from '../AutoScroll/DebugInspector';
|
||||
import { styles } from './style';
|
||||
|
||||
export interface BackBottomProps {
|
||||
atBottom: boolean;
|
||||
onScrollToBottom: () => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const BackBottom = memo<BackBottomProps>(({ visible, onScrollToBottom }) => {
|
||||
const BackBottom = memo<BackBottomProps>(({ visible, atBottom, onScrollToBottom }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
className={cx(styles.container, visible && styles.visible)}
|
||||
glass
|
||||
icon={ArrowDownIcon}
|
||||
onClick={onScrollToBottom}
|
||||
size={{
|
||||
blockSize: 36,
|
||||
borderRadius: 36,
|
||||
size: 18,
|
||||
}}
|
||||
title={t('backToBottom')}
|
||||
variant={'outlined'}
|
||||
/>
|
||||
<>
|
||||
{/* Debug: 底部指示线 */}
|
||||
{OPEN_DEV_INSPECTOR && (
|
||||
<div
|
||||
style={{
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
{/* Threshold 区域顶部边界线 */}
|
||||
<div
|
||||
style={{
|
||||
background: atBottom ? '#22c55e' : '#ef4444',
|
||||
height: 2,
|
||||
left: 0,
|
||||
opacity: 0.5,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: -AT_BOTTOM_THRESHOLD,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Threshold 区域 mask - 显示在指示线上方 */}
|
||||
<div
|
||||
style={{
|
||||
background: atBottom
|
||||
? 'linear-gradient(to top, rgba(34, 197, 94, 0.15), transparent)'
|
||||
: 'linear-gradient(to top, rgba(239, 68, 68, 0.1), transparent)',
|
||||
height: AT_BOTTOM_THRESHOLD,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: -AT_BOTTOM_THRESHOLD,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* AutoScroll 位置指示线(底部) */}
|
||||
<div
|
||||
style={{
|
||||
background: atBottom ? '#22c55e' : '#ef4444',
|
||||
height: 2,
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActionIcon
|
||||
className={cx(styles.container, visible && styles.visible)}
|
||||
glass
|
||||
icon={ArrowDownIcon}
|
||||
onClick={onScrollToBottom}
|
||||
size={{
|
||||
blockSize: 36,
|
||||
borderRadius: 36,
|
||||
size: 18,
|
||||
}}
|
||||
title={t('backToBottom')}
|
||||
variant={'outlined'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ import { type ReactElement, type ReactNode, memo, useCallback, useEffect, useRef
|
||||
import { VList, type VListHandle } from 'virtua';
|
||||
|
||||
import WideScreenContainer from '../../../WideScreenContainer';
|
||||
import { useConversationStore, virtuaListSelectors } from '../../store';
|
||||
import { dataSelectors, useConversationStore, virtuaListSelectors } from '../../store';
|
||||
import { useScrollToUserMessage } from '../hooks/useScrollToUserMessage';
|
||||
import AutoScroll from './AutoScroll';
|
||||
import DebugInspector, {
|
||||
AT_BOTTOM_THRESHOLD,
|
||||
OPEN_DEV_INSPECTOR,
|
||||
} from './AutoScroll/DebugInspector';
|
||||
import BackBottom from './BackBottom';
|
||||
|
||||
interface VirtualizedListProps {
|
||||
dataSource: string[];
|
||||
@@ -24,7 +26,6 @@ interface VirtualizedListProps {
|
||||
*/
|
||||
const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent }) => {
|
||||
const virtuaRef = useRef<VListHandle>(null);
|
||||
const prevDataLengthRef = useRef(dataSource.length);
|
||||
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Store actions
|
||||
@@ -112,15 +113,18 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
||||
};
|
||||
}, [resetVisibleItems]);
|
||||
|
||||
// Auto scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
const shouldScroll = dataSource.length > prevDataLengthRef.current;
|
||||
prevDataLengthRef.current = dataSource.length;
|
||||
// Get the last message to check if it's a user message
|
||||
const displayMessages = useConversationStore(dataSelectors.displayMessages);
|
||||
const lastMessage = displayMessages.at(-1);
|
||||
const isLastMessageFromUser = lastMessage?.role === 'user';
|
||||
|
||||
if (shouldScroll && virtuaRef.current) {
|
||||
virtuaRef.current.scrollToIndex(dataSource.length - 2, { align: 'start', smooth: true });
|
||||
}
|
||||
}, [dataSource.length]);
|
||||
// Auto scroll to user message when user sends a new message
|
||||
// Only scroll when the new message is from the user, not when AI/agent responds
|
||||
useScrollToUserMessage({
|
||||
dataSourceLength: dataSource.length,
|
||||
isLastMessageFromUser,
|
||||
scrollToIndex: virtuaRef.current?.scrollToIndex ?? null,
|
||||
});
|
||||
|
||||
// Scroll to bottom on initial render
|
||||
useEffect(() => {
|
||||
@@ -129,8 +133,11 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
||||
}
|
||||
}, []);
|
||||
|
||||
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
|
||||
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ height: '100%', position: 'relative' }}>
|
||||
{/* Debug Inspector - 放在 VList 外面,不会被虚拟列表回收 */}
|
||||
{OPEN_DEV_INSPECTOR && <DebugInspector />}
|
||||
<VList
|
||||
@@ -143,15 +150,16 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
||||
>
|
||||
{(messageId, index): ReactElement => {
|
||||
const isAgentCouncil = messageId.includes('agentCouncil');
|
||||
const isLastItem = index === dataSource.length - 1;
|
||||
const content = itemContent(index, messageId);
|
||||
const isLast = index === dataSource.length - 1;
|
||||
|
||||
if (isAgentCouncil) {
|
||||
// AgentCouncil needs full width for horizontal scroll
|
||||
return (
|
||||
<div key={messageId} style={{ position: 'relative', width: '100%' }}>
|
||||
{content}
|
||||
{isLast && <AutoScroll />}
|
||||
{/* AutoScroll 放在最后一个 Item 里面,这样只有当最后一个 Item 可见时才会触发自动滚动 */}
|
||||
{isLastItem && <AutoScroll />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,12 +167,15 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
||||
return (
|
||||
<WideScreenContainer key={messageId} style={{ position: 'relative' }}>
|
||||
{content}
|
||||
{isLast && <AutoScroll />}
|
||||
{/* AutoScroll 放在最后一个 Item 里面,这样只有当最后一个 Item 可见时才会触发自动滚动 */}
|
||||
{isLastItem && <AutoScroll />}
|
||||
</WideScreenContainer>
|
||||
);
|
||||
}}
|
||||
</VList>
|
||||
</>
|
||||
{/* BackBottom 放在 VList 外面,这样无论滚动到哪里都能看到 */}
|
||||
<BackBottom atBottom={atBottom} onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />
|
||||
</div>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useScrollToUserMessage } from './useScrollToUserMessage';
|
||||
|
||||
describe('useScrollToUserMessage', () => {
|
||||
describe('when user sends a new message', () => {
|
||||
it('should scroll to user message when new message is from user', () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ dataSourceLength, isLastMessageFromUser }) =>
|
||||
useScrollToUserMessage({
|
||||
dataSourceLength,
|
||||
isLastMessageFromUser,
|
||||
scrollToIndex,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
dataSourceLength: 2,
|
||||
isLastMessageFromUser: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// User sends a new message (length increases, last message is from user)
|
||||
rerender({
|
||||
dataSourceLength: 3,
|
||||
isLastMessageFromUser: true,
|
||||
});
|
||||
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
||||
expect(scrollToIndex).toHaveBeenCalledWith(1, { align: 'start', smooth: true });
|
||||
});
|
||||
|
||||
it('should scroll to correct index when multiple user messages are sent', () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ dataSourceLength, isLastMessageFromUser }) =>
|
||||
useScrollToUserMessage({
|
||||
dataSourceLength,
|
||||
isLastMessageFromUser,
|
||||
scrollToIndex,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
dataSourceLength: 5,
|
||||
isLastMessageFromUser: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// User sends a new message
|
||||
rerender({
|
||||
dataSourceLength: 6,
|
||||
isLastMessageFromUser: true,
|
||||
});
|
||||
|
||||
// Should scroll to index 4 (dataSourceLength - 2 = 6 - 2 = 4)
|
||||
expect(scrollToIndex).toHaveBeenCalledWith(4, { align: 'start', smooth: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when AI/agent responds', () => {
|
||||
it('should NOT scroll when new message is from AI', () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ dataSourceLength, isLastMessageFromUser }) =>
|
||||
useScrollToUserMessage({
|
||||
dataSourceLength,
|
||||
isLastMessageFromUser,
|
||||
scrollToIndex,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
dataSourceLength: 2,
|
||||
isLastMessageFromUser: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// AI responds (length increases, but last message is NOT from user)
|
||||
rerender({
|
||||
dataSourceLength: 3,
|
||||
isLastMessageFromUser: false,
|
||||
});
|
||||
|
||||
expect(scrollToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT scroll when multiple agents respond in group chat', () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ dataSourceLength, isLastMessageFromUser }) =>
|
||||
useScrollToUserMessage({
|
||||
dataSourceLength,
|
||||
isLastMessageFromUser,
|
||||
scrollToIndex,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
dataSourceLength: 3,
|
||||
isLastMessageFromUser: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// First agent responds
|
||||
rerender({
|
||||
dataSourceLength: 4,
|
||||
isLastMessageFromUser: false,
|
||||
});
|
||||
|
||||
expect(scrollToIndex).not.toHaveBeenCalled();
|
||||
|
||||
// Second agent responds
|
||||
rerender({
|
||||
dataSourceLength: 5,
|
||||
isLastMessageFromUser: false,
|
||||
});
|
||||
|
||||
expect(scrollToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should NOT scroll when length decreases (message deleted)', () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ dataSourceLength, isLastMessageFromUser }) =>
|
||||
useScrollToUserMessage({
|
||||
dataSourceLength,
|
||||
isLastMessageFromUser,
|
||||
scrollToIndex,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
dataSourceLength: 5,
|
||||
isLastMessageFromUser: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Message deleted (length decreases)
|
||||
rerender({
|
||||
dataSourceLength: 4,
|
||||
isLastMessageFromUser: true,
|
||||
});
|
||||
|
||||
expect(scrollToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT scroll when length stays the same', () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ dataSourceLength, isLastMessageFromUser }) =>
|
||||
useScrollToUserMessage({
|
||||
dataSourceLength,
|
||||
isLastMessageFromUser,
|
||||
scrollToIndex,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
dataSourceLength: 3,
|
||||
isLastMessageFromUser: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Length stays the same (content update, not new message)
|
||||
rerender({
|
||||
dataSourceLength: 3,
|
||||
isLastMessageFromUser: true,
|
||||
});
|
||||
|
||||
expect(scrollToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle null scrollToIndex gracefully', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ dataSourceLength, isLastMessageFromUser }) =>
|
||||
useScrollToUserMessage({
|
||||
dataSourceLength,
|
||||
isLastMessageFromUser,
|
||||
scrollToIndex: null,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
dataSourceLength: 2,
|
||||
isLastMessageFromUser: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Should not throw when scrollToIndex is null
|
||||
expect(() => {
|
||||
rerender({
|
||||
dataSourceLength: 3,
|
||||
isLastMessageFromUser: true,
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should NOT scroll on initial render', () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useScrollToUserMessage({
|
||||
dataSourceLength: 5,
|
||||
isLastMessageFromUser: true,
|
||||
scrollToIndex,
|
||||
}),
|
||||
);
|
||||
|
||||
// Should not scroll on initial render even if last message is from user
|
||||
expect(scrollToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface UseScrollToUserMessageOptions {
|
||||
/**
|
||||
* Current data source length (number of messages)
|
||||
*/
|
||||
dataSourceLength: number;
|
||||
/**
|
||||
* Whether the last message is from the user
|
||||
*/
|
||||
isLastMessageFromUser: boolean;
|
||||
/**
|
||||
* Function to scroll to a specific index
|
||||
*/
|
||||
scrollToIndex:
|
||||
| ((index: number, options?: { align?: 'start' | 'center' | 'end'; smooth?: boolean }) => void)
|
||||
| null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle scrolling to user message when user sends a new message.
|
||||
* Only triggers scroll when the new message is from the user, not when AI/agent responds.
|
||||
*
|
||||
* This ensures that in group chat scenarios, when multiple agents are responding,
|
||||
* the view doesn't jump around as each agent starts speaking.
|
||||
*/
|
||||
export function useScrollToUserMessage({
|
||||
dataSourceLength,
|
||||
isLastMessageFromUser,
|
||||
scrollToIndex,
|
||||
}: UseScrollToUserMessageOptions): void {
|
||||
const prevLengthRef = useRef(dataSourceLength);
|
||||
|
||||
useEffect(() => {
|
||||
const hasNewMessage = dataSourceLength > prevLengthRef.current;
|
||||
prevLengthRef.current = dataSourceLength;
|
||||
|
||||
// Only scroll when user sends a new message
|
||||
if (hasNewMessage && isLastMessageFromUser && scrollToIndex) {
|
||||
// Scroll to the second-to-last message (user's message) with the start aligned
|
||||
scrollToIndex(dataSourceLength - 2, { align: 'start', smooth: true });
|
||||
}
|
||||
}, [dataSourceLength, isLastMessageFromUser, scrollToIndex]);
|
||||
}
|
||||
289
src/hooks/useAutoScroll.test.ts
Normal file
289
src/hooks/useAutoScroll.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAutoScroll } from './useAutoScroll';
|
||||
|
||||
describe('useAutoScroll', () => {
|
||||
let rafCallbacks: FrameRequestCallback[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCallbacks.push(cb);
|
||||
return rafCallbacks.length;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const flushRAF = () => {
|
||||
const callbacks = [...rafCallbacks];
|
||||
rafCallbacks = [];
|
||||
callbacks.forEach((cb) => cb(performance.now()));
|
||||
};
|
||||
|
||||
const createMockContainer = (scrollTop = 0, scrollHeight = 1000, clientHeight = 400) => {
|
||||
return {
|
||||
clientHeight,
|
||||
scrollHeight,
|
||||
scrollTop,
|
||||
} as HTMLDivElement;
|
||||
};
|
||||
|
||||
describe('when enabled changes from true to false (streaming ends)', () => {
|
||||
it('should maintain scroll position when streaming ends', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
|
||||
{ initialProps: { content: 'initial', enabled: true } },
|
||||
);
|
||||
|
||||
// Simulate container scrolled to bottom (scrollTop = scrollHeight - clientHeight = 600)
|
||||
const mockContainer = createMockContainer(600, 1000, 400);
|
||||
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
||||
|
||||
// Trigger auto-scroll with content change while streaming
|
||||
rerender({ content: 'updated content', enabled: true });
|
||||
|
||||
act(() => {
|
||||
flushRAF();
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
// Should scroll to bottom (scrollTop = scrollHeight = 1000)
|
||||
expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
|
||||
|
||||
// Record scroll position before disabling
|
||||
const scrollPositionBeforeDisable = mockContainer.scrollTop;
|
||||
|
||||
// Now simulate streaming end: enabled becomes false
|
||||
// This is where the bug occurs - the hook stops maintaining scroll position
|
||||
rerender({ content: 'final content', enabled: false });
|
||||
|
||||
act(() => {
|
||||
flushRAF();
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
// BUG TEST: After enabled becomes false, scroll position should be maintained
|
||||
// Currently, the hook doesn't actively preserve position when disabled,
|
||||
// which can cause scroll to reset when DOM changes occur
|
||||
expect(mockContainer.scrollTop).toBe(scrollPositionBeforeDisable);
|
||||
});
|
||||
|
||||
it('should actively restore scroll position when DOM resets it after enabled becomes false', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
|
||||
{ initialProps: { content: 'initial', enabled: true } },
|
||||
);
|
||||
|
||||
const mockContainer = createMockContainer(600, 1000, 400);
|
||||
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
||||
|
||||
// Auto-scroll to bottom while streaming
|
||||
rerender({ content: 'streaming content...', enabled: true });
|
||||
|
||||
act(() => {
|
||||
flushRAF();
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
expect(mockContainer.scrollTop).toBe(1000);
|
||||
|
||||
// Record the scroll position at bottom
|
||||
const scrollPositionAtBottom = mockContainer.scrollTop;
|
||||
|
||||
// Streaming ends - enabled becomes false
|
||||
rerender({ content: 'final content', enabled: false });
|
||||
|
||||
// Simulate DOM change that resets scroll position to top
|
||||
// This happens in real browsers when content re-renders
|
||||
mockContainer.scrollTop = 0;
|
||||
|
||||
act(() => {
|
||||
flushRAF();
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
// BUG: The hook should restore scroll position when enabled transitions from true to false
|
||||
// Currently it does nothing when enabled=false, so scroll position stays at 0
|
||||
// Expected behavior: hook should detect enabled transition and restore position
|
||||
expect(mockContainer.scrollTop).toBe(scrollPositionAtBottom);
|
||||
});
|
||||
|
||||
it('should preserve scroll position when user has scrolled and streaming ends', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
|
||||
{ initialProps: { content: 'initial', enabled: true } },
|
||||
);
|
||||
|
||||
// Container at middle position (user scrolled up)
|
||||
const mockContainer = createMockContainer(300, 1000, 400);
|
||||
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
||||
|
||||
// Simulate user scroll (triggers userHasScrolled = true)
|
||||
act(() => {
|
||||
result.current.handleScroll();
|
||||
});
|
||||
|
||||
expect(result.current.userHasScrolled).toBe(true);
|
||||
|
||||
const scrollPositionBeforeDisable = mockContainer.scrollTop;
|
||||
|
||||
// Streaming ends
|
||||
rerender({ content: 'final content', enabled: false });
|
||||
|
||||
act(() => {
|
||||
flushRAF();
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
// Position should remain unchanged
|
||||
expect(mockContainer.scrollTop).toBe(scrollPositionBeforeDisable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic auto-scroll functionality', () => {
|
||||
it('should auto-scroll to bottom when deps change and enabled is true', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true }),
|
||||
{ initialProps: { content: 'initial' } },
|
||||
);
|
||||
|
||||
const mockContainer = createMockContainer(0, 1000, 400);
|
||||
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
||||
|
||||
rerender({ content: 'new content' });
|
||||
|
||||
act(() => {
|
||||
flushRAF();
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
|
||||
});
|
||||
|
||||
it('should not auto-scroll when enabled is false', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: false }),
|
||||
{ initialProps: { content: 'initial' } },
|
||||
);
|
||||
|
||||
const mockContainer = createMockContainer(100, 1000, 400);
|
||||
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
||||
const initialScrollTop = mockContainer.scrollTop;
|
||||
|
||||
rerender({ content: 'new content' });
|
||||
|
||||
act(() => {
|
||||
flushRAF();
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
expect(mockContainer.scrollTop).toBe(initialScrollTop);
|
||||
});
|
||||
|
||||
it('should stop auto-scroll when user scrolls away from bottom', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ content }) =>
|
||||
useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true, threshold: 20 }),
|
||||
{ initialProps: { content: 'initial' } },
|
||||
);
|
||||
|
||||
// Container NOT at bottom (distance to bottom > threshold)
|
||||
const mockContainer = createMockContainer(100, 1000, 400);
|
||||
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
||||
|
||||
// Simulate user scroll event
|
||||
act(() => {
|
||||
result.current.handleScroll();
|
||||
});
|
||||
|
||||
expect(result.current.userHasScrolled).toBe(true);
|
||||
|
||||
// Content changes but should not auto-scroll due to user scroll lock
|
||||
const scrollTopBeforeUpdate = mockContainer.scrollTop;
|
||||
rerender({ content: 'new content' });
|
||||
|
||||
act(() => {
|
||||
flushRAF();
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
expect(mockContainer.scrollTop).toBe(scrollTopBeforeUpdate);
|
||||
});
|
||||
|
||||
it('should reset scroll lock when resetScrollLock is called', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true }),
|
||||
{ initialProps: { content: 'initial' } },
|
||||
);
|
||||
|
||||
const mockContainer = createMockContainer(100, 1000, 400);
|
||||
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
||||
|
||||
// User scrolls away
|
||||
act(() => {
|
||||
result.current.handleScroll();
|
||||
});
|
||||
|
||||
expect(result.current.userHasScrolled).toBe(true);
|
||||
|
||||
// Reset scroll lock
|
||||
act(() => {
|
||||
result.current.resetScrollLock();
|
||||
});
|
||||
|
||||
expect(result.current.userHasScrolled).toBe(false);
|
||||
|
||||
// Now auto-scroll should work again
|
||||
rerender({ content: 'new content' });
|
||||
|
||||
act(() => {
|
||||
flushRAF();
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threshold behavior', () => {
|
||||
it('should not set userHasScrolled when at bottom within threshold', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAutoScroll<HTMLDivElement>({ deps: [], enabled: true, threshold: 20 }),
|
||||
);
|
||||
|
||||
// Container at bottom (distance = scrollHeight - scrollTop - clientHeight = 1000 - 590 - 400 = 10 < 20)
|
||||
const mockContainer = createMockContainer(590, 1000, 400);
|
||||
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
||||
|
||||
act(() => {
|
||||
result.current.handleScroll();
|
||||
});
|
||||
|
||||
// Should NOT set userHasScrolled because we're within threshold
|
||||
expect(result.current.userHasScrolled).toBe(false);
|
||||
});
|
||||
|
||||
it('should set userHasScrolled when scrolled beyond threshold', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAutoScroll<HTMLDivElement>({ deps: [], enabled: true, threshold: 20 }),
|
||||
);
|
||||
|
||||
// Container NOT at bottom (distance = 1000 - 500 - 400 = 100 > 20)
|
||||
const mockContainer = createMockContainer(500, 1000, 400);
|
||||
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
||||
|
||||
act(() => {
|
||||
result.current.handleScroll();
|
||||
});
|
||||
|
||||
expect(result.current.userHasScrolled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,7 @@ export function useAutoScroll<T extends HTMLElement = HTMLDivElement>(
|
||||
const ref = useRef<T | null>(null);
|
||||
const [userHasScrolled, setUserHasScrolled] = useState(false);
|
||||
const isAutoScrollingRef = useRef(false);
|
||||
const prevEnabledRef = useRef(enabled);
|
||||
|
||||
// Handle user scroll detection
|
||||
const handleScroll = useCallback(() => {
|
||||
@@ -91,6 +92,28 @@ export function useAutoScroll<T extends HTMLElement = HTMLDivElement>(
|
||||
setUserHasScrolled(false);
|
||||
}, []);
|
||||
|
||||
// Preserve scroll position when enabled transitions from true to false (streaming ends)
|
||||
// This prevents scroll position from being lost when DOM re-renders after streaming
|
||||
useEffect(() => {
|
||||
const container = ref.current;
|
||||
if (!container) return;
|
||||
|
||||
// Detect enabled transition from true to false
|
||||
if (prevEnabledRef.current && !enabled) {
|
||||
const currentScrollTop = container.scrollTop;
|
||||
isAutoScrollingRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
// Restore scroll position in case DOM changes reset it
|
||||
container.scrollTop = currentScrollTop;
|
||||
requestAnimationFrame(() => {
|
||||
isAutoScrollingRef.current = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
prevEnabledRef.current = enabled;
|
||||
}, [enabled]);
|
||||
|
||||
// Auto scroll to bottom when deps change (unless user has scrolled or disabled)
|
||||
useEffect(() => {
|
||||
if (!enabled || userHasScrolled) return;
|
||||
|
||||
Reference in New Issue
Block a user