Skip to content

Commit 3a64610

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 3a64610

1 file changed

Lines changed: 12 additions & 48 deletions

File tree

chat/src/components/message-list.tsx

Lines changed: 12 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,6 @@ export default function MessageList({messages}: MessageListProps) {
2828

2929
// Track if user is at bottom - default to true for initial scroll
3030
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);
3531

3632
const checkIfAtBottom = useCallback(() => {
3733
if (!scrollAreaRef) return false;
@@ -60,58 +56,26 @@ export default function MessageList({messages}: MessageListProps) {
6056
};
6157
}, []);
6258

63-
// Update isAtBottom on scroll
59+
// Track whether the user is scrolled to the bottom. Every scroll event
60+
// updates the ref so auto-scroll decisions are always based on the
61+
// user's actual position.
6462
useEffect(() => {
6563
if (!scrollAreaRef) return;
66-
6764
const handleScroll = () => {
68-
if (isProgrammaticScrollRef.current) return;
6965
isAtBottomRef.current = checkIfAtBottom();
7066
};
71-
72-
// Initial check
7367
handleScroll();
74-
7568
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-
};
69+
return () => scrollAreaRef.removeEventListener("scroll", handleScroll);
8270
}, [checkIfAtBottom, scrollAreaRef]);
8371

84-
// Handle auto-scrolling when messages change
72+
// Pin to bottom when new content arrives, but only if the user hasn't
73+
// scrolled away. Direct scrollTop assignment is synchronous and avoids
74+
// the animation conflicts that smooth scrollTo causes during streaming.
8575
useLayoutEffect(() => {
8676
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;
77+
if (!isAtBottomRef.current) return;
78+
scrollAreaRef.scrollTop = scrollAreaRef.scrollHeight;
11579
}, [messages, scrollAreaRef]);
11680

11781
// If no messages, show a placeholder
@@ -126,7 +90,7 @@ export default function MessageList({messages}: MessageListProps) {
12690
return (
12791
<div className="overflow-y-auto flex-1" ref={setScrollAreaRef}>
12892
<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">
93+
className="p-4 flex flex-col gap-4 max-w-4xl mx-auto min-h-0">
13094
{messages.map((message, index) => (
13195
<div
13296
key={message.id ?? "draft"}
@@ -137,7 +101,7 @@ export default function MessageList({messages}: MessageListProps) {
137101
message.role === "user"
138102
? "bg-accent-foreground rounded-lg max-w-[90%] px-4 py-3 text-accent"
139103
: "max-w-[80ch]"
140-
} ${message.id === undefined ? "animate-pulse" : ""}`}
104+
}`}
141105
>
142106
<div
143107
className={`whitespace-pre-wrap break-words text-left text-xs md:text-sm leading-relaxed md:leading-normal ${
@@ -186,7 +150,7 @@ const ProcessedMessage = React.memo(function ProcessedMessage({
186150
}: ProcessedMessageProps) {
187151
// Regex to find URLs
188152
// 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, []);
153+
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,})/, []);
190154

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

0 commit comments

Comments
 (0)