perf: reduce chat streaming rerenders

- Keep historical chat messages stable during streaming
- Isolate active stream state to the current message path
- Reduce chat container churn from sync message updates
This commit is contained in:
Bohdan Triapitsyn
2026-04-05 16:56:05 +03:00
parent e56f2f99cf
commit c88e68e5f7
4 changed files with 93 additions and 30 deletions
@@ -106,6 +106,7 @@ type ChatViewportProps = {
isLoadingOlder: boolean;
sessionIsWorking: boolean;
streamingMessageId: string | null;
activeStreamingPhase: import('./message/types').StreamPhase | null;
retryOverlay: {
sessionId: string;
message: string;
@@ -135,6 +136,7 @@ const ChatViewport = React.memo(({
isLoadingOlder,
sessionIsWorking,
streamingMessageId,
activeStreamingPhase,
retryOverlay,
handleMessageContentChange,
getAnimationHandlers,
@@ -173,6 +175,7 @@ const ChatViewport = React.memo(({
messages={renderedMessages}
sessionIsWorking={sessionIsWorking}
activeStreamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
@@ -218,6 +221,7 @@ const ChatViewport = React.memo(({
&& prev.isLoadingOlder === next.isLoadingOlder
&& prev.sessionIsWorking === next.sessionIsWorking
&& prev.streamingMessageId === next.streamingMessageId
&& prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.retryOverlay === next.retryOverlay
&& prev.handleMessageContentChange === next.handleMessageContentChange
&& prev.getAnimationHandlers === next.getAnimationHandlers
@@ -296,6 +300,15 @@ export const ChatContainer: React.FC = () => {
[currentSessionId],
),
);
const activeStreamingPhase = useStreamingStore(
React.useCallback(
(s) => {
if (!streamingMessageId) return null;
return s.messageStreamStates.get(streamingMessageId)?.phase ?? null;
},
[streamingMessageId],
),
);
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
const [suspendDetachedTailUpdates, setSuspendDetachedTailUpdates] = React.useState(false);
// Messages from sync system
@@ -803,6 +816,7 @@ export const ChatContainer: React.FC = () => {
isLoadingOlder={timelineController.isLoadingOlder}
sessionIsWorking={sessionIsWorking}
streamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
handleMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
@@ -8,7 +8,6 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useContextStore } from '@/stores/contextStore';
import { useStreamingStore } from '@/sync/streaming';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
@@ -131,6 +130,7 @@ interface ChatMessageProps {
turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string;
isInActiveTurn?: boolean;
activeStreamingPhase?: StreamPhase | null;
animateUserOnMount?: boolean;
onUserAnimationConsumed?: (messageId: string) => void;
}
@@ -144,6 +144,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
turnGroupingContext,
assistantHeaderMessageId,
isInActiveTurn = false,
activeStreamingPhase = null,
animateUserOnMount = false,
onUserAnimationConsumed,
}) => {
@@ -152,13 +153,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const streamState = useStreamingStore((s) => s.messageStreamStates.get(message.info.id));
const lifecyclePhase = isInActiveTurn ? (streamState?.phase ?? null) : null;
const msgSessionId = (message.info as { sessionID?: string }).sessionID ?? currentSessionId ?? null;
const streamingMsgForSession = useStreamingStore((s) => msgSessionId ? s.streamingMessageIds.get(msgSessionId) ?? null : null);
const isStreamingMessage = isInActiveTurn ? streamingMsgForSession === message.info.id : false;
const hasActiveStreamInSession = typeof streamingMsgForSession === 'string' && streamingMsgForSession.length > 0;
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
@@ -166,13 +160,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const forkFromMessage = sessionActions.forkFromMessage;
streamPerfCount('ui.chat_message.render');
if (isStreamingMessage) {
if (isInActiveTurn) {
streamPerfCount('ui.chat_message.render.streaming');
} else if (hasActiveStreamInSession) {
streamPerfCount('ui.chat_message.render.static_during_stream');
if (!isInActiveTurn) {
streamPerfCount('ui.chat_message.render.static_outside_active_turn_during_stream');
}
}
const providers = useConfigStore.getState().providers;
@@ -596,11 +585,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
if (isMessageCompleted) {
return 'completed';
}
if (lifecyclePhase) {
return lifecyclePhase;
if (isInActiveTurn) {
return activeStreamingPhase ?? 'streaming';
}
return isStreamingMessage ? 'streaming' : 'completed';
}, [isMessageCompleted, lifecyclePhase, isStreamingMessage]);
return 'completed';
}, [activeStreamingPhase, isInActiveTurn, isMessageCompleted]);
React.useEffect(() => {
if (!isUser || !animateUserOnMount) {
@@ -1148,6 +1137,7 @@ export default React.memo(ChatMessage, (prev, next) => {
&& prev.turnGroupingContext === next.turnGroupingContext
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
&& prev.isInActiveTurn === next.isInActiveTurn
&& prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.animateUserOnMount === next.animateUserOnMount
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed;
});
@@ -14,6 +14,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 40;
const MESSAGE_LIST_OVERSCAN = 6;
@@ -304,6 +305,7 @@ interface MessageListProps {
messages: ChatMessageEntry[];
sessionIsWorking?: boolean;
activeStreamingMessageId?: string | null;
activeStreamingPhase?: StreamPhase | null;
retryOverlay?: {
sessionId: string;
message: string;
@@ -347,6 +349,7 @@ interface MessageRowProps {
turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string;
isInActiveTurn?: boolean;
activeStreamingPhase?: StreamPhase | null;
animateUserOnMount?: boolean;
onUserAnimationConsumed?: (messageId: string) => void;
onContentChange: (reason?: ContentChangeReason) => void;
@@ -361,6 +364,7 @@ const MessageRow = React.memo<MessageRowProps>(({
turnGroupingContext,
assistantHeaderMessageId,
isInActiveTurn,
activeStreamingPhase,
animateUserOnMount,
onUserAnimationConsumed,
onContentChange,
@@ -380,6 +384,7 @@ const MessageRow = React.memo<MessageRowProps>(({
turnGroupingContext={turnGroupingContext}
assistantHeaderMessageId={assistantHeaderMessageId}
isInActiveTurn={isInActiveTurn}
activeStreamingPhase={activeStreamingPhase}
/>
);
}, (prev, next) => {
@@ -404,6 +409,7 @@ const MessageRow = React.memo<MessageRowProps>(({
&& prevTurn?.activityParts === nextTurn?.activityParts
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
&& prev.isInActiveTurn === next.isInActiveTurn
&& prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.animationHandlers?.onChunk === next.animationHandlers?.onChunk
&& prev.animationHandlers?.onComplete === next.animationHandlers?.onComplete
&& prev.animationHandlers?.onStreamingCandidate === next.animationHandlers?.onStreamingCandidate
@@ -430,6 +436,7 @@ interface TurnBlockProps {
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
activeStreamingPhase?: StreamPhase | null;
}
const TurnBlock: React.FC<TurnBlockProps> = ({
@@ -447,6 +454,7 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
shouldAnimateUserMessage,
onUserAnimationConsumed,
activeStreamingMessageId,
activeStreamingPhase,
}) => {
const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
const handleToggleTurnGroup = React.useCallback(() => {
@@ -656,6 +664,7 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
turnGroupingContext={turnGroupingContext}
assistantHeaderMessageId={assistantHeaderMessageId}
isInActiveTurn={Boolean(streamingAssistantMessageId) && message.info.id === streamingAssistantMessageId}
activeStreamingPhase={message.info.id === streamingAssistantMessageId ? activeStreamingPhase : null}
animateUserOnMount={shouldAnimateUserMessage(message)}
onUserAnimationConsumed={onUserAnimationConsumed}
onContentChange={onMessageContentChange}
@@ -681,6 +690,7 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
turnUiState.isExpanded,
turnGroupingContextBase,
streamingAssistantMessageId,
activeStreamingPhase,
visibleAssistantMessages,
visibleAssistantIds,
activityOwnerMessageId,
@@ -717,6 +727,7 @@ interface UngroupedMessageRowProps {
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
activeStreamingPhase?: StreamPhase | null;
}
const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = React.memo(({
@@ -729,6 +740,7 @@ const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = React.memo(({
shouldAnimateUserMessage,
onUserAnimationConsumed,
activeStreamingMessageId,
activeStreamingPhase,
}) => {
return (
<MessageRow
@@ -741,6 +753,7 @@ const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = React.memo(({
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
isInActiveTurn={Boolean(activeStreamingMessageId) && message.info.id === activeStreamingMessageId}
activeStreamingPhase={message.info.id === activeStreamingMessageId ? activeStreamingPhase : null}
/>
);
}, (prev, next) => {
@@ -752,7 +765,8 @@ const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = React.memo(({
&& prev.scrollToBottom === next.scrollToBottom
&& prev.shouldAnimateUserMessage === next.shouldAnimateUserMessage
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
&& prev.activeStreamingMessageId === next.activeStreamingMessageId;
&& prev.activeStreamingMessageId === next.activeStreamingMessageId
&& prev.activeStreamingPhase === next.activeStreamingPhase;
});
UngroupedMessageRow.displayName = 'UngroupedMessageRow';
@@ -771,6 +785,7 @@ interface MessageListEntryProps {
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
activeStreamingPhase?: StreamPhase | null;
}
const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | undefined): boolean => {
@@ -799,6 +814,7 @@ const MessageListEntry: React.FC<MessageListEntryProps> = React.memo(({
shouldAnimateUserMessage,
onUserAnimationConsumed,
activeStreamingMessageId,
activeStreamingPhase,
}) => {
if (entry.kind === 'ungrouped') {
return (
@@ -812,6 +828,7 @@ const MessageListEntry: React.FC<MessageListEntryProps> = React.memo(({
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
activeStreamingPhase={activeStreamingPhase}
/>
);
}
@@ -828,6 +845,7 @@ const MessageListEntry: React.FC<MessageListEntryProps> = React.memo(({
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
activeStreamingPhase={activeStreamingPhase}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
@@ -871,6 +889,22 @@ function areMessageListEntryPropsEqual(prevProps: MessageListEntryProps, nextPro
}
}
if (prevProps.activeStreamingPhase !== nextProps.activeStreamingPhase) {
const prevAffected = turnContainsMessageId(prevEntry.turn, prevProps.activeStreamingMessageId);
const nextAffected = turnContainsMessageId(nextEntry.turn, nextProps.activeStreamingMessageId);
if (prevAffected || nextAffected) {
return false;
}
}
if (prevProps.activeStreamingPhase !== nextProps.activeStreamingPhase) {
const prevAffected = turnContainsMessageId(prevEntry.turn, prevProps.activeStreamingMessageId);
const nextAffected = turnContainsMessageId(nextEntry.turn, nextProps.activeStreamingMessageId);
if (prevAffected || nextAffected) {
return false;
}
}
return true;
}
@@ -884,6 +918,20 @@ function areMessageListEntryPropsEqual(prevProps: MessageListEntryProps, nextPro
}
}
if (prevProps.activeStreamingPhase !== nextProps.activeStreamingPhase) {
const messageId = prevEntry.message.info.id;
if (prevProps.activeStreamingMessageId === messageId || nextProps.activeStreamingMessageId === messageId) {
return false;
}
}
if (prevProps.activeStreamingPhase !== nextProps.activeStreamingPhase) {
const messageId = prevEntry.message.info.id;
if (prevProps.activeStreamingMessageId === messageId || nextProps.activeStreamingMessageId === messageId) {
return false;
}
}
return (
areRenderRelevantMessagesEqual(prevEntry.message, nextEntry.message)
&& areOptionalRenderRelevantMessagesEqual(prevEntry.previousMessage, nextEntry.previousMessage)
@@ -912,7 +960,8 @@ const StaticHistoryList: React.FC<{
chatRenderMode: 'sorted' | 'live';
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
}> = React.memo(({ entries, shouldVirtualize, virtualRows, totalSize, measureElement, contentRef, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed }) => {
activeStreamingPhase?: StreamPhase | null;
}> = React.memo(({ entries, shouldVirtualize, virtualRows, totalSize, measureElement, contentRef, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingPhase }) => {
const renderEntry = React.useCallback((entry: RenderEntry) => {
return (
<MessageListEntry
@@ -930,9 +979,10 @@ const StaticHistoryList: React.FC<{
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={null}
activeStreamingPhase={activeStreamingPhase}
/>
);
}, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
}, [activeStreamingPhase, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
const paddingTop = shouldVirtualize && virtualRows.length > 0
? virtualRows[0]?.start ?? 0
@@ -995,7 +1045,8 @@ const StaticHistoryList: React.FC<{
&& prevProps.onToggleTurnGroup === nextProps.onToggleTurnGroup
&& prevProps.chatRenderMode === nextProps.chatRenderMode
&& prevProps.shouldAnimateUserMessage === nextProps.shouldAnimateUserMessage
&& prevProps.onUserAnimationConsumed === nextProps.onUserAnimationConsumed;
&& prevProps.onUserAnimationConsumed === nextProps.onUserAnimationConsumed
&& prevProps.activeStreamingPhase === nextProps.activeStreamingPhase;
});
StaticHistoryList.displayName = 'StaticHistoryList';
@@ -1014,6 +1065,7 @@ const StreamingTailContent: React.FC<{
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
activeStreamingPhase?: StreamPhase | null;
}> = React.memo(({
entry,
onMessageContentChange,
@@ -1028,6 +1080,7 @@ const StreamingTailContent: React.FC<{
shouldAnimateUserMessage,
onUserAnimationConsumed,
activeStreamingMessageId,
activeStreamingPhase,
}) => {
return (
<MessageListEntry
@@ -1044,6 +1097,7 @@ const StreamingTailContent: React.FC<{
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
activeStreamingPhase={activeStreamingPhase}
/>
);
}, (prev, next) => {
@@ -1059,7 +1113,8 @@ const StreamingTailContent: React.FC<{
&& prev.chatRenderMode === next.chatRenderMode
&& prev.shouldAnimateUserMessage === next.shouldAnimateUserMessage
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
&& prev.activeStreamingMessageId === next.activeStreamingMessageId;
&& prev.activeStreamingMessageId === next.activeStreamingMessageId
&& prev.activeStreamingPhase === next.activeStreamingPhase;
});
StreamingTailContent.displayName = 'StreamingTailContent';
@@ -1071,6 +1126,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
messages,
sessionIsWorking = false,
activeStreamingMessageId = null,
activeStreamingPhase = null,
retryOverlay = null,
onMessageContentChange,
getAnimationHandlers,
@@ -1760,6 +1816,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingPhase={activeStreamingPhase}
/>
{trailingStreamingEntry ? (
<StreamingTailContent
@@ -1776,6 +1833,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
activeStreamingPhase={activeStreamingPhase}
/>
) : null}
</div>
+8 -7
View File
@@ -36,7 +36,14 @@ type SyncSystem = {
directory: string
}
const SyncContext = createContext<SyncSystem | null>(null)
const SYNC_CONTEXT_GLOBAL_KEY = "__openchamber_sync_context__"
type SyncGlobal = typeof globalThis & {
[SYNC_CONTEXT_GLOBAL_KEY]?: React.Context<SyncSystem | null>
}
const syncGlobal = globalThis as SyncGlobal
const SyncContext = syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] ?? createContext<SyncSystem | null>(null)
syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] = SyncContext
function useSyncSystem() {
const ctx = useContext(SyncContext)
@@ -1072,9 +1079,3 @@ const EMPTY_MESSAGES: Message[] = []
const EMPTY_PARTS: Part[] = []
const EMPTY_PERMISSION_REQUESTS: PermissionRequest[] = []
const EMPTY_QUESTION_REQUESTS: QuestionRequest[] = []
if (import.meta.hot) {
import.meta.hot.accept(() => {
import.meta.hot?.invalidate()
})
}