diff --git a/.jules/bolt.md b/.jules/bolt.md index f1a8c146..ce45a8ae 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -77,3 +77,6 @@ Optimized metric route processing to O(N) by creating a mapping of routes direct ## 2024-07-13 - [Optimize Export Dictionary FK lookups] **Learning:** Found O(N * C * E) performance bottleneck in ERD export dictionaries due to repeated array searching with `edges.some()` inside a nested loop over nodes and columns. **Action:** Replace repeated linear array scans for edges by precomputing O(1) Set lookups of foreign key column handles per node before looping. +## 2026-07-16 - Avoid React.memo breakage in React Flow high-frequency updates +**Learning:** During high-frequency updates in React Flow (like node dragging at 60fps), `nodes` array mapping that creates a new `data` object reference (e.g., to inject `isDimmed`/`isHighlighted`) breaks the fast-path `prev.data === next.data` in `TableNode`'s `React.memo`, triggering deep re-renders across the entire graph. Additionally, O(N*C) computations (like text search over all nodes' columns) attached to the `nodes` dependency array will be redundantly re-evaluated on every frame. +**Action:** Since React Flow creates new object references for nodes when dragged but keeps the `node.data` reference stable, use a `WeakMap` keyed by `node.data` to cache derived or decorated data and search results. This preserves object identity, avoiding full re-renders and repeated heavy computations during dragging operations. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d0dc258..fc95d198 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -197,17 +197,33 @@ export default function App() { const searchMatchedNodeIds = useMemo(() => { return findSearchMatchedNodeIds(nodes, normalizedNodeSearch); }, [nodes, normalizedNodeSearch]); + + const visibleNodesDataCache = useRef(new WeakMap>()); + const visibleNodes = useMemo(() => { if (!normalizedNodeSearch) return nodes; return nodes.map((node) => { const isHighlighted = searchMatchedNodeIds.has(node.id); - return { - ...node, - data: { + + let cacheMap = visibleNodesDataCache.current.get(node.data); + if (!cacheMap) { + cacheMap = new Map(); + visibleNodesDataCache.current.set(node.data, cacheMap); + } + + let decoratedData = cacheMap.get(isHighlighted); + if (!decoratedData) { + decoratedData = { ...node.data, isDimmed: !isHighlighted, isHighlighted, - }, + }; + cacheMap.set(isHighlighted, decoratedData); + } + + return { + ...node, + data: decoratedData, }; }); }, [nodes, normalizedNodeSearch, searchMatchedNodeIds]); @@ -382,7 +398,7 @@ export default function App() { }, [ cardinalityColumnSelections, cardinalityDistinctCounts, - cardinalityNode, + cardinalityNode?.data, ]); const cardinalityRecommendations = useMemo( () => diff --git a/frontend/src/erd/search.ts b/frontend/src/erd/search.ts index 51806947..75129dc7 100644 --- a/frontend/src/erd/search.ts +++ b/frontend/src/erd/search.ts @@ -32,6 +32,11 @@ export function tableNodeMatchesSearch( return terms.every((term) => nodeIncludesTerm(node, term)); } +// ⚡ Bolt: WeakMap to cache search matches by node.data. +// Since `node.data` remains stable during drags, this avoids re-evaluating the O(N*C) search function +// across all nodes at 60fps when dragging while a search filter is active. +const searchCache = new WeakMap>(); + export function findSearchMatchedNodeIds( nodes: Array>, search: string, @@ -39,13 +44,29 @@ export function findSearchMatchedNodeIds( const matches = new Set(); // ⚡ Bolt: Parse search terms ONCE outside the loop (O(1)) instead of inside tableNodeMatchesSearch for every node (O(N)), // eliminating redundant string allocations, regex splits, and Sets per node. + const trimmedSearch = search.trim().toLocaleLowerCase(); const terms = Array.from( - new Set(search.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)), + new Set(trimmedSearch.split(/\s+/).filter(Boolean)), ); if (terms.length === 0) return matches; + // Use a predictable cache key for this search. + const searchKey = terms.join(" "); + for (const node of nodes) { - if (tableNodeMatchesSearch(node, terms)) { + let nodeCache = searchCache.get(node.data); + if (!nodeCache) { + nodeCache = new Map(); + searchCache.set(node.data, nodeCache); + } + + let isMatch = nodeCache.get(searchKey); + if (isMatch === undefined) { + isMatch = tableNodeMatchesSearch(node, terms); + nodeCache.set(searchKey, isMatch); + } + + if (isMatch) { matches.add(node.id); } }