diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 00000000..55a02061 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,9 @@ +# Bolt Journal - Performance Learnings + +## 2025-05-14 - React State Synchronization Optimization +**Learning:** Using `useState` + `useEffect` to synchronize state derived from props (e.g., `showEmptyScreen` from `messages.length`) causes unnecessary extra render cycles. +**Action:** Always prefer derived variables (`const showEmptyScreen = messages.length === 0`) for simple UI logic to ensure immediate updates and cleaner code. + +## 2025-05-14 - Chat UI Re-render Prevention +**Learning:** In a chat application, the main message list (`ChatMessages`) often re-renders whenever the user types in the input field because they share a parent component (`Chat`). This is especially expensive if the message list performs computations like grouping. +**Action:** Wrap high-frequency UI components like `ChatMessages` in `React.memo` and use `useMemo` for any O(n) data transformations (like message grouping) to keep the UI responsive during user interaction. diff --git a/components/chat-messages.tsx b/components/chat-messages.tsx index 6bfa3642..0d8bd216 100644 --- a/components/chat-messages.tsx +++ b/components/chat-messages.tsx @@ -1,43 +1,50 @@ 'use client' -import { StreamableValue, useUIState } from 'ai/rsc' -import type { AI, UIState } from '@/app/actions' +import { memo, useMemo } from 'react' +import { StreamableValue } from 'ai/rsc' +import type { UIState } from '@/app/actions' import { CollapsibleMessage } from './collapsible-message' interface ChatMessagesProps { messages: UIState } -export function ChatMessages({ messages }: ChatMessagesProps) { - if (!messages.length) { - return null - } - +export const ChatMessages = memo(({ messages }: ChatMessagesProps) => { // Group messages based on ID, and if there are multiple messages with the same ID, combine them into one message - const groupedMessages = messages.reduce( - (acc: { [key: string]: any }, message) => { - if (!acc[message.id]) { - acc[message.id] = { - id: message.id, - components: [], - isCollapsed: message.isCollapsed + const groupedMessagesArray = useMemo(() => { + if (!messages.length) { + return [] + } + + const groupedMessages = messages.reduce( + (acc: { [key: string]: any }, message) => { + if (!acc[message.id]) { + acc[message.id] = { + id: message.id, + components: [], + isCollapsed: message.isCollapsed + } } - } - acc[message.id].components.push(message.component) - return acc - }, - {} - ) + acc[message.id].components.push(message.component) + return acc + }, + {} + ) - // Convert grouped messages into an array with explicit type - const groupedMessagesArray = Object.values(groupedMessages).map(group => ({ - ...group, - components: group.components as React.ReactNode[] - })) as { - id: string - components: React.ReactNode[] - isCollapsed?: StreamableValue - }[] + // Convert grouped messages into an array with explicit type + return Object.values(groupedMessages).map(group => ({ + ...group, + components: (group as any).components as React.ReactNode[] + })) as { + id: string + components: React.ReactNode[] + isCollapsed?: StreamableValue + }[] + }, [messages]) + + if (!messages.length) { + return null + } return ( <> @@ -47,8 +54,7 @@ export function ChatMessages({ messages }: ChatMessagesProps) { id: string components: React.ReactNode[] isCollapsed?: StreamableValue - }, - index + } ) => ( ) -} +}) + +ChatMessages.displayName = 'ChatMessages' diff --git a/components/chat.tsx b/components/chat.tsx index e675f124..ba78066f 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -36,7 +36,7 @@ export function Chat({ id }: ChatProps) { const { isUsageOpen } = useUsageToggle(); const { isCalendarOpen } = useCalendarToggle() const [input, setInput] = useState('') - const [showEmptyScreen, setShowEmptyScreen] = useState(false) + const showEmptyScreen = messages.length === 0 const [isSubmitting, setIsSubmitting] = useState(false) const [suggestions, setSuggestions] = useState(null) const chatPanelRef = useRef(null); @@ -48,10 +48,6 @@ export function Chat({ id }: ChatProps) { const handleMobileSubmit = () => { chatPanelRef.current?.submitForm(); }; - - useEffect(() => { - setShowEmptyScreen(messages.length === 0) - }, [messages]) useEffect(() => { // Check if device is mobile