From 79ff849f67b1009904d9fb986399ec1b8ac69f27 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:52:19 +0000 Subject: [PATCH] bolt: optimize chat performance via state colocation and memoization - Moved high-frequency \`input\` state from \`Chat\` to \`ChatPanel\` to prevent whole-app re-renders on keystrokes. - Exposed \`submitForm\` via \`useImperativeHandle\` in \`ChatPanel\` for programmatic submissions. - Wrapped \`ChatMessages\` in \`React.memo\` and memoized message grouping logic. - Fixed a Rules of Hooks violation in \`ChatMessages\`. - Added performance learnings to \`.jules/bolt.md\`. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- .jules/bolt.md | 6 ++++ components/chat-messages.tsx | 68 +++++++++++++++++++----------------- components/chat-panel.tsx | 24 +++++++++---- components/chat.tsx | 23 +++--------- 4 files changed, 63 insertions(+), 58 deletions(-) create mode 100644 .jules/bolt.md diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 00000000..a57ac42a --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,6 @@ +## 2025-05-14 - [State Colocation & Memoization] +**Learning:** Moving high-frequency state (like text input) from a large parent container (Chat) to a leaf component (ChatPanel) eliminates massive re-render cycles across the entire application shell (Header, Map, History). Even with React's efficient diffing, the overhead of executing render functions for large component trees on every keystroke causes noticeable lag in complex interfaces. + +**Learning:** When using early returns for empty states (e.g., "no messages"), always ensure `useMemo` and other hooks are declared BEFORE the return to avoid breaking the Rules of Hooks, even if the computation isn't needed for the empty state. + +**Action:** Always check for high-frequency state in parent components during profiling. Use `useImperativeHandle` to maintain parent-to-child control for "one-off" events (like suggestions) while keeping state local. diff --git a/components/chat-messages.tsx b/components/chat-messages.tsx index 6bfa3642..096a2bf5 100644 --- a/components/chat-messages.tsx +++ b/components/chat-messages.tsx @@ -3,51 +3,53 @@ import { StreamableValue, useUIState } from 'ai/rsc' import type { AI, UIState } from '@/app/actions' import { CollapsibleMessage } from './collapsible-message' +import React, { useMemo } from 'react' interface ChatMessagesProps { messages: UIState } -export function ChatMessages({ messages }: ChatMessagesProps) { - if (!messages.length) { - return null - } - +export const ChatMessages = React.memo(function ChatMessages({ 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.components as React.ReactNode[] + })) as { + id: string + components: React.ReactNode[] + isCollapsed?: StreamableValue + }[] + }, [messages]) + + if (!messages.length) { + return null + } return ( <> {groupedMessagesArray.map( ( - groupedMessage: { - id: string - components: React.ReactNode[] - isCollapsed?: StreamableValue - }, + groupedMessage, index ) => ( ) -} +}) diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index ca2fbc6f..95ca836e 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -17,17 +17,17 @@ import SuggestionsDropdown from './suggestions-dropdown' interface ChatPanelProps { messages: UIState - input: string - setInput: (value: string) => void onSuggestionsChange?: (suggestions: PartialRelated | null) => void } export interface ChatPanelRef { handleAttachmentClick: () => void - submitForm: () => void + submitForm: (value?: string) => void } -export const ChatPanel = forwardRef(({ messages, input, setInput, onSuggestionsChange }, ref) => { +export const ChatPanel = forwardRef(({ messages, onSuggestionsChange }, ref) => { + const [input, setInput] = useState('') + const [triggerSubmit, setTriggerSubmit] = useState(0) const [, setMessages] = useUIState() const { submit, clearChat } = useActions() const { mapProvider } = useSettingsStore() @@ -48,11 +48,22 @@ export const ChatPanel = forwardRef(({ messages, i handleAttachmentClick() { fileInputRef.current?.click() }, - submitForm() { - formRef.current?.requestSubmit() + submitForm(value?: string) { + if (value !== undefined) { + setInput(value) + setTriggerSubmit(prev => prev + 1) + } else { + formRef.current?.requestSubmit() + } } })); + useEffect(() => { + if (triggerSubmit > 0) { + formRef.current?.requestSubmit() + } + }, [triggerSubmit]) + // Detect mobile layout useEffect(() => { const checkMobile = () => { @@ -127,6 +138,7 @@ export const ChatPanel = forwardRef(({ messages, i const handleClear = async () => { setMessages([]) + setInput('') clearAttachment() await clearChat() } diff --git a/components/chat.tsx b/components/chat.tsx index e675f124..e239d503 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -35,9 +35,7 @@ export function Chat({ id }: ChatProps) { const { activeView } = useProfileToggle(); const { isUsageOpen } = useUsageToggle(); const { isCalendarOpen } = useCalendarToggle() - const [input, setInput] = useState('') const [showEmptyScreen, setShowEmptyScreen] = useState(false) - const [isSubmitting, setIsSubmitting] = useState(false) const [suggestions, setSuggestions] = useState(null) const chatPanelRef = useRef(null); @@ -85,12 +83,6 @@ export function Chat({ id }: ChatProps) { // Get mapData to access drawnFeatures const { mapData } = useMapData(); - useEffect(() => { - if (isSubmitting) { - chatPanelRef.current?.submitForm() - setIsSubmitting(false) - } - }, [isSubmitting]) // useEffect to call the server action when drawnFeatures changes useEffect(() => { @@ -110,10 +102,8 @@ export function Chat({ id }: ChatProps) { { - setInput(query) + chatPanelRef.current?.submitForm(query) setSuggestions(null) - // Use a small timeout to ensure state update before submission - setIsSubmitting(true) }} onClose={() => setSuggestions(null)} className="relative bottom-auto mb-0 w-full shadow-none border-none bg-transparent" @@ -138,8 +128,6 @@ export function Chat({ id }: ChatProps) { @@ -152,8 +140,7 @@ export function Chat({ id }: ChatProps) { {showEmptyScreen ? ( { - setInput(message) - setIsSubmitting(true) + chatPanelRef.current?.submitForm(message) }} /> ) : ( @@ -181,9 +168,8 @@ export function Chat({ id }: ChatProps) { ) : ( <>
@@ -191,8 +177,7 @@ export function Chat({ id }: ChatProps) { {showEmptyScreen ? ( { - setInput(message) - setIsSubmitting(true) + chatPanelRef.current?.submitForm(message) }} /> ) : (