diff --git a/.jules/bolt.md b/.jules/bolt.md index f1a8c146..116578f1 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-23 - Preserve React Flow object identity during 60fps updates +**Learning:** During drag events, React Flow updates node positions at 60fps. Deriving a filtered or augmented `visibleNodes` list using unconditional spread syntax (`...node.data`) inside a `useMemo` forces the creation of new `node.data` references for *every* node on *every* frame. This completely bypasses the `React.memo` (which checks `prev.data === next.data`) on individual custom nodes, causing full application re-renders and massive stuttering. +**Action:** When decorating or mapping node state in React Flow that is passed down to components (e.g. `isDimmed`, `isHighlighted` during search), always use a `WeakMap` cached outside the component (keyed by the stable original `node.data` reference) to store and reuse the decorated object. This preserves object identity and keeps the fast-path alive. diff --git a/frontend/src/App.coverage.test.tsx b/frontend/src/App.coverage.test.tsx index e38650db..4ea647fe 100644 --- a/frontend/src/App.coverage.test.tsx +++ b/frontend/src/App.coverage.test.tsx @@ -783,6 +783,7 @@ describe('App orchestration coverage', () => { }) await renderReadyApp() fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) + await waitFor(() => expect(screen.getAllByRole('button', { name: '열기' }).length).toBeGreaterThan(0)) vi.useFakeTimers() fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) await act(async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d0dc258..f88586f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,6 +65,10 @@ import { GRID_COLUMNS, GRID_X_GAP, GRID_Y_GAP } from "./erd/layoutConstants"; import { findSearchMatchedNodeIds } from "./erd/search"; import type { Connection, Project, Snapshot, SnapshotDetail } from "./types"; +// ⚡ Bolt: Use a WeakMap outside the component to cache decorated `node.data` objects. +// This prevents breaking React.memo on 60fps drag updates when a search filter is active. +const nodeDataDecorationCache = new WeakMap>(); + const TERMINAL_SNAPSHOT_STATUSES = new Set([ "succeeded", "failed", @@ -201,13 +205,26 @@ export default function App() { if (!normalizedNodeSearch) return nodes; return nodes.map((node) => { const isHighlighted = searchMatchedNodeIds.has(node.id); - return { - ...node, - data: { + + let innerCache = nodeDataDecorationCache.get(node.data); + if (!innerCache) { + innerCache = new Map(); + nodeDataDecorationCache.set(node.data, innerCache); + } + + let decoratedData = innerCache.get(isHighlighted); + if (!decoratedData) { + decoratedData = { ...node.data, isDimmed: !isHighlighted, isHighlighted, - }, + }; + innerCache.set(isHighlighted, decoratedData); + } + + return { + ...node, + data: decoratedData, }; }); }, [nodes, normalizedNodeSearch, searchMatchedNodeIds]);