diff --git a/.jules/bolt.md b/.jules/bolt.md index 0e8670e..678e65a 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -31,3 +31,7 @@ ## 2026-05-03 - [O(N) Cascade re-renders in Lists with State-Driven Hover] **Learning:** Prop-drilling or managing local interactive state (like `hoveredId` on `mouseenter`/`mouseleave`) within a list component mapping an array of items (like `ChatHistory` mapping `SessionItem`s) creates O(N) re-renders when interacting with any single item. **Action:** Always favor native CSS pseudo-classes like `:hover` or Tailwind's `group-hover` over interactive React state for visually toggling elements inside rendered lists, and wrap the parent list component with `React.memo` if it sits alongside frequently updating sibling states (like a timer or streaming tokens). + +## 2024-05-04 - Unnecessary Timer Re-renders +**Learning:** App.tsx has a 250ms setInterval updating state (runElapsed) while the agent is running, which forces the entire app tree (including large lists in FileExplorer and ChatPanel) to re-render 4 times a second. +**Action:** Always wrap large list components and recursive tree components (like FileExplorer's Row) in React.memo() when they are descendants of a component with high-frequency state updates. Also use useMemo for mapping large lists (like chat messages) to prevent unnecessary VDOM recreation. diff --git a/packages/desktop/src/renderer/components/FileExplorer.tsx b/packages/desktop/src/renderer/components/FileExplorer.tsx index 4eb9b9d..b788b56 100644 --- a/packages/desktop/src/renderer/components/FileExplorer.tsx +++ b/packages/desktop/src/renderer/components/FileExplorer.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, memo } from 'react'; interface FileExplorerProps { workingDir: string; @@ -32,7 +32,8 @@ function color(name: string, dir: boolean): string { return 'text-xibe-text-dim'; } -function Row({ node, depth, open, kids, onToggle }: { node: FileEntry; depth: number; open: boolean; kids?: FileEntry[]; onToggle: (n: FileEntry) => void }) { +/* ⚡ Bolt: Memoized Row component to prevent O(N) re-renders of the file tree on timer ticks */ +const Row = memo(function Row({ node, depth, open, kids, onToggle }: { node: FileEntry; depth: number; open: boolean; kids?: FileEntry[]; onToggle: (n: FileEntry) => void }) { return (
); -} +}); -export default function FileExplorer({ workingDir }: FileExplorerProps) { +export default memo(function FileExplorer({ workingDir }: FileExplorerProps) { const [tree, setTree] = useState([]); const [expanded, setExpanded] = useState>(new Set()); @@ -63,16 +64,18 @@ export default function FileExplorer({ workingDir }: FileExplorerProps) { useEffect(() => { load(workingDir).then(setTree); }, [workingDir, load]); - const toggle = async (node: FileEntry) => { + const toggle = useCallback(async (node: FileEntry) => { if (!node.isDirectory) return; - const next = new Set(expanded); - next.has(node.path) ? next.delete(node.path) : next.add(node.path); - setExpanded(next); + setExpanded((prev) => { + const next = new Set(prev); + next.has(node.path) ? next.delete(node.path) : next.add(node.path); + return next; + }); if (!node.loaded) { const children = await load(node.path); setTree((prev) => update(prev, node.path, { children, loaded: true })); } - }; + }, [load]); return (
@@ -82,4 +85,4 @@ export default function FileExplorer({ workingDir }: FileExplorerProps) {
); -} +});