Skip to content

Commit 5cc443d

Browse files
authored
🤖 perf: fix CLS and React long tasks (#1004)
## Summary Fix two performance issues identified via Chrome DevTools profiling: ### 1. Fix Layout Shift (CLS = 0.39 → <0.1) The thinking indicator in `ReasoningMessage` was conditionally rendered, causing elements below to shift when it appeared/disappeared. This contributed **0.32** to the CLS score alone. **Fix:** Always render the container and use CSS `height`/`opacity` transitions instead of conditional rendering. Track expanded height with `useLayoutEffect` for smooth animations. ### 2. Reduce React Long Tasks (719ms → shorter, interruptible) Chrome profiling showed `performWorkUntilDeadline` running for up to **719ms** during message list updates, blocking the main thread. **Fix:** - Wrap `mergeConsecutiveStreamErrors` in `useMemo` for memoization - Use `useDeferredValue` for the message list rendering - Allows React to defer heavy reconciliation during streaming, keeping UI responsive ### Testing - `make typecheck` ✅ - `make lint` ✅ _Generated with `mux`_
1 parent dd1d753 commit 5cc443d

File tree

2 files changed

+48
-18
lines changed

2 files changed

+48
-18
lines changed

‎src/browser/components/AIView.tsx‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import React, { useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
1+
import React, {
2+
useState,
3+
useCallback,
4+
useEffect,
5+
useLayoutEffect,
6+
useRef,
7+
useDeferredValue,
8+
useMemo,
9+
} from "react";
210
import { cn } from "@/common/lib/utils";
311
import { MessageRenderer } from "./Messages/MessageRenderer";
412
import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier";
@@ -156,6 +164,13 @@ const AIViewInner: React.FC<AIViewProps> = ({
156164
// Extract state from workspace state
157165
const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState;
158166

167+
// Merge consecutive identical stream errors.
168+
// Use useDeferredValue to allow React to defer the heavy message list rendering
169+
// during rapid updates (streaming), keeping the UI responsive.
170+
// Must be defined before any early returns to satisfy React Hooks rules.
171+
const mergedMessages = useMemo(() => mergeConsecutiveStreamErrors(messages), [messages]);
172+
const deferredMessages = useDeferredValue(mergedMessages);
173+
159174
// Get active stream message ID for token counting
160175
const activeStreamMessageId = aggregator?.getActiveStreamMessageId();
161176

@@ -418,9 +433,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
418433
// If user pressed the interrupt key, autoRetry stays false until they manually retry.
419434
// This makes state transitions explicit and predictable.
420435

421-
// Merge consecutive identical stream errors
422-
const mergedMessages = mergeConsecutiveStreamErrors(messages);
423-
424436
// When editing, find the cutoff point
425437
const editCutoffHistoryId = editingMessage
426438
? mergedMessages.find(
@@ -502,9 +514,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
502514
>
503515
<div
504516
ref={innerRef}
505-
className={cn("max-w-4xl mx-auto", mergedMessages.length === 0 && "h-full")}
517+
className={cn("max-w-4xl mx-auto", deferredMessages.length === 0 && "h-full")}
506518
>
507-
{mergedMessages.length === 0 ? (
519+
{deferredMessages.length === 0 ? (
508520
<div className="text-placeholder flex h-full flex-1 flex-col items-center justify-center text-center [&_h3]:m-0 [&_h3]:mb-2.5 [&_h3]:text-base [&_h3]:font-medium [&_p]:m-0 [&_p]:text-[13px]">
509521
<h3>No Messages Yet</h3>
510522
<p>Send a message below to begin</p>
@@ -520,7 +532,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
520532
</div>
521533
) : (
522534
<>
523-
{mergedMessages.map((msg) => {
535+
{deferredMessages.map((msg) => {
524536
const isAtCutoff =
525537
editCutoffHistoryId !== undefined &&
526538
msg.type !== "history-hidden" &&

‎src/browser/components/Messages/ReasoningMessage.tsx‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect } from "react";
1+
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
22
import type { DisplayedMessage } from "@/common/types/message";
33
import { MarkdownRenderer } from "./MarkdownRenderer";
44
import { TypewriterMarkdown } from "./TypewriterMarkdown";
@@ -15,6 +15,9 @@ const REASONING_FONT_CLASSES = "font-primary text-[12px] leading-[18px]";
1515

1616
export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, className }) => {
1717
const [isExpanded, setIsExpanded] = useState(true);
18+
// Track the height when expanded to reserve space during collapse transitions
19+
const [expandedHeight, setExpandedHeight] = useState<number | null>(null);
20+
const contentRef = useRef<HTMLDivElement>(null);
1821

1922
const content = message.content;
2023
const isStreaming = message.isStreaming;
@@ -26,6 +29,14 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
2629
const isSingleLineTrace = !isStreaming && hasContent && !hasAdditionalLines;
2730
const isCollapsible = !isStreaming && hasContent && hasAdditionalLines;
2831
const showEllipsis = isCollapsible && !isExpanded;
32+
const showExpandedContent = isExpanded && !isSingleLineTrace;
33+
34+
// Capture expanded height before collapsing to enable smooth transitions
35+
useLayoutEffect(() => {
36+
if (contentRef.current && isExpanded && !isSingleLineTrace) {
37+
setExpandedHeight(contentRef.current.scrollHeight);
38+
}
39+
}, [isExpanded, isSingleLineTrace, content]);
2940

3041
// Auto-collapse when streaming ends
3142
useEffect(() => {
@@ -118,16 +129,23 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
118129
)}
119130
</div>
120131

121-
{isExpanded && !isSingleLineTrace && (
122-
<div
123-
className={cn(
124-
REASONING_FONT_CLASSES,
125-
"italic opacity-85 [&_p]:mt-0 [&_p]:mb-1 [&_p:last-child]:mb-0"
126-
)}
127-
>
128-
{renderContent()}
129-
</div>
130-
)}
132+
{/* Always render the content container to prevent layout shifts.
133+
Use CSS transitions for smooth height changes instead of conditional rendering. */}
134+
<div
135+
ref={contentRef}
136+
className={cn(
137+
REASONING_FONT_CLASSES,
138+
"italic opacity-85 [&_p]:mt-0 [&_p]:mb-1 [&_p:last-child]:mb-0",
139+
"overflow-hidden transition-[height,opacity] duration-200 ease-in-out"
140+
)}
141+
style={{
142+
height: showExpandedContent ? (expandedHeight ?? "auto") : 0,
143+
opacity: showExpandedContent ? 1 : 0,
144+
}}
145+
aria-hidden={!showExpandedContent}
146+
>
147+
{renderContent()}
148+
</div>
131149
</div>
132150
);
133151
};

0 commit comments

Comments
 (0)