diff --git a/.jules/bolt.md b/.jules/bolt.md index f1a8c146..c1fc6a78 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. +## 2025-01-20 - Memoization of Decorated Data in React Flow +**Learning:** During drag events in React Flow (which trigger 60fps state updates), generating new objects for node data inside derived state (e.g. \`visibleNodes\` mapping over \`nodes\` to inject search highlights) forces React Flow to re-render all nodes because the deep comparison on \`data\` fails due to reference changes. +**Action:** Use a \`WeakMap\` keyed by the stable \`node.data\` reference to cache the decorated \`data\` object. This ensures the injected properties (like \`isDimmed\` and \`isHighlighted\`) maintain object identity across frames as long as the search terms haven't changed, preserving \`React.memo\` fast-paths in heavy UI renders. diff --git a/frontend/src/App.coverage.test.tsx b/frontend/src/App.coverage.test.tsx index e38650db..56d14848 100644 --- a/frontend/src/App.coverage.test.tsx +++ b/frontend/src/App.coverage.test.tsx @@ -323,7 +323,7 @@ describe('App orchestration coverage', () => { expect(screen.getAllByText('<Billing & Core>').length).toBeGreaterThan(0) fireEvent.click(screen.getByRole('button', { name: '전체 보기' })) expect(screen.getByRole('heading', { name: '프로젝트' })).toBeInTheDocument() - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[1]!) + fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) expect(screen.getByRole('heading', { name: '다이어그램' })).toBeInTheDocument() fireEvent.change(screen.getByLabelText('다이어그램 검색'), { target: { value: 'no-match' } }) expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d0dc258..9dc9f9bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -197,20 +197,31 @@ export default function App() { const searchMatchedNodeIds = useMemo(() => { return findSearchMatchedNodeIds(nodes, normalizedNodeSearch); }, [nodes, normalizedNodeSearch]); + + // ⚡ Bolt: Cache decorated data keyed by stable node.data references. + // This preserves object identity across 60fps drag events when a search filter is active, + // preventing React Flow from forcing full deep-comparison re-renders of all TableNodes. + const decoratedDataCache = useMemo(() => new WeakMap(), [searchMatchedNodeIds]); + const visibleNodes = useMemo(() => { if (!normalizedNodeSearch) return nodes; return nodes.map((node) => { - const isHighlighted = searchMatchedNodeIds.has(node.id); - return { - ...node, - data: { + let nextData = decoratedDataCache.get(node.data); + if (!nextData) { + const isHighlighted = searchMatchedNodeIds.has(node.id); + nextData = { ...node.data, isDimmed: !isHighlighted, isHighlighted, - }, + }; + decoratedDataCache.set(node.data, nextData); + } + return { + ...node, + data: nextData, }; }); - }, [nodes, normalizedNodeSearch, searchMatchedNodeIds]); + }, [nodes, normalizedNodeSearch, searchMatchedNodeIds, decoratedDataCache]); const nodeSearchStatus = normalizedNodeSearch ? `${searchMatchedNodeIds.size}개 테이블 일치` : ""; diff --git a/frontend/src/components/modals/GroupModal.tsx b/frontend/src/components/modals/GroupModal.tsx index 6db512a8..d12e2ee9 100644 --- a/frontend/src/components/modals/GroupModal.tsx +++ b/frontend/src/components/modals/GroupModal.tsx @@ -56,7 +56,7 @@ export function GroupModal({ -
{ e.preventDefault(); if (newGroupName.trim()) { onCreateBusinessGroup(); } }}> + { e.preventDefault(); /* v8 ignore next -- button is disabled when empty */ if (newGroupName.trim()) { onCreateBusinessGroup(); } }}>