Skip to content

Commit 8fea7bb

Browse files
committed
fix(chat): reduce rendering jank during streaming
The chat view stuttered during streaming responses due to several compounding issues: transition-all on the message container animated height changes as messages grew, smooth scroll was interrupted on every streaming chunk before the previous animation completed, the scrollend listener used anonymous functions so cleanup never matched and broke user scroll-away detection, and the URL regex /g flag made it stateful across split/test calls causing link flicker.
1 parent b434b09 commit 8fea7bb

1 file changed

Lines changed: 14 additions & 54 deletions

File tree

chat/src/components/message-list.tsx

Lines changed: 14 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,15 @@ interface ProcessedMessageProps {
2525

2626
export default function MessageList({messages}: MessageListProps) {
2727
const [scrollAreaRef, setScrollAreaRef] = useState<HTMLDivElement | null>(null);
28-
29-
// Track if user is at bottom - default to true for initial scroll
3028
const isAtBottomRef = useRef(true);
31-
// Track the last known scroll height to detect new content
32-
const lastScrollHeightRef = useRef(0);
33-
// Track if we're currently doing a programmatic scroll
34-
const isProgrammaticScrollRef = useRef(false);
3529

3630
const checkIfAtBottom = useCallback(() => {
3731
if (!scrollAreaRef) return false;
3832
const { scrollTop, scrollHeight, clientHeight } = scrollAreaRef;
39-
return scrollTop + clientHeight >= scrollHeight - 10; // 10px tolerance
33+
return scrollTop + clientHeight >= scrollHeight - 10;
4034
}, [scrollAreaRef]);
4135

42-
// Track Ctrl (Windows/Linux) or Cmd (Mac) key state
43-
// This is so that underline is only visible when hover + cmd/ctrl
36+
// Track Ctrl (Windows/Linux) or Cmd (Mac) key state for link underlines.
4437
useEffect(() => {
4538
const handleKeyDown = (e: KeyboardEvent) => {
4639
if (e.ctrlKey || e.metaKey) document.documentElement.classList.add('modifier-pressed');
@@ -56,62 +49,29 @@ export default function MessageList({messages}: MessageListProps) {
5649
window.removeEventListener("keydown", handleKeyDown);
5750
window.removeEventListener("keyup", handleKeyUp);
5851
document.documentElement.classList.remove('modifier-pressed');
59-
6052
};
6153
}, []);
6254

63-
// Update isAtBottom on scroll
55+
// Track whether the user is scrolled to the bottom. Every scroll event
56+
// updates the ref so auto-scroll decisions are always based on the
57+
// user's actual position.
6458
useEffect(() => {
6559
if (!scrollAreaRef) return;
66-
6760
const handleScroll = () => {
68-
if (isProgrammaticScrollRef.current) return;
6961
isAtBottomRef.current = checkIfAtBottom();
7062
};
71-
72-
// Initial check
7363
handleScroll();
74-
7564
scrollAreaRef.addEventListener("scroll", handleScroll);
76-
scrollAreaRef.addEventListener("scrollend", () => isProgrammaticScrollRef.current = false);
77-
return () => {
78-
scrollAreaRef.removeEventListener("scroll", handleScroll)
79-
scrollAreaRef.removeEventListener("scrollend", () => isProgrammaticScrollRef.current = false);
80-
81-
};
65+
return () => scrollAreaRef.removeEventListener("scroll", handleScroll);
8266
}, [checkIfAtBottom, scrollAreaRef]);
8367

84-
// Handle auto-scrolling when messages change
68+
// Pin to bottom when new content arrives, but only if the user hasn't
69+
// scrolled away. Direct scrollTop assignment is synchronous and avoids
70+
// the animation conflicts that smooth scrollTo causes during streaming.
8571
useLayoutEffect(() => {
8672
if (!scrollAreaRef) return;
87-
88-
const currentScrollHeight = scrollAreaRef.scrollHeight;
89-
90-
// Check if this is new content (scroll height increased)
91-
const hasNewContent = currentScrollHeight > lastScrollHeightRef.current;
92-
const isFirstRender = lastScrollHeightRef.current === 0;
93-
const isNewUserMessage =
94-
messages.length > 0 && messages[messages.length - 1].role === "user";
95-
96-
// Auto-scroll only if:
97-
// 1. It's the first render, OR
98-
// 2. There's new content AND user was at the bottom, OR
99-
// 3. The user sent a new message
100-
if (
101-
hasNewContent &&
102-
(isFirstRender || isAtBottomRef.current || isNewUserMessage)
103-
) {
104-
isProgrammaticScrollRef.current = true;
105-
scrollAreaRef.scrollTo({
106-
top: currentScrollHeight,
107-
behavior: isFirstRender ? "instant" : "smooth",
108-
});
109-
// After scrolling, we're at the bottom
110-
isAtBottomRef.current = true;
111-
}
112-
113-
// Update the last known scroll height
114-
lastScrollHeightRef.current = currentScrollHeight;
73+
if (!isAtBottomRef.current) return;
74+
scrollAreaRef.scrollTop = scrollAreaRef.scrollHeight;
11575
}, [messages, scrollAreaRef]);
11676

11777
// If no messages, show a placeholder
@@ -126,7 +86,7 @@ export default function MessageList({messages}: MessageListProps) {
12686
return (
12787
<div className="overflow-y-auto flex-1" ref={setScrollAreaRef}>
12888
<div
129-
className="p-4 flex flex-col gap-4 max-w-4xl mx-auto transition-all duration-300 ease-in-out min-h-0">
89+
className="p-4 flex flex-col gap-4 max-w-4xl mx-auto min-h-0">
13090
{messages.map((message, index) => (
13191
<div
13292
key={message.id ?? "draft"}
@@ -137,7 +97,7 @@ export default function MessageList({messages}: MessageListProps) {
13797
message.role === "user"
13898
? "bg-accent-foreground rounded-lg max-w-[90%] px-4 py-3 text-accent"
13999
: "max-w-[80ch]"
140-
} ${message.id === undefined ? "animate-pulse" : ""}`}
100+
}`}
141101
>
142102
<div
143103
className={`whitespace-pre-wrap break-words text-left text-xs md:text-sm leading-relaxed md:leading-normal ${
@@ -186,7 +146,7 @@ const ProcessedMessage = React.memo(function ProcessedMessage({
186146
}: ProcessedMessageProps) {
187147
// Regex to find URLs
188148
// https://stackoverflow.com/a/17773849
189-
const urlRegex = useMemo<RegExp>(() => /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g, []);
149+
const urlRegex = useMemo<RegExp>(() => /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/, []);
190150

191151
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, url: string) => {
192152
if (e.metaKey || e.ctrlKey) {

0 commit comments

Comments
 (0)