From 9c3675edc7cef61964135b1f4836d45d6460be11 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:46:49 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Bolt:=20Optimize=20node=20search=20?= =?UTF-8?q?filtering=20to=20prevent=20GC=20pressure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactored high-frequency search filtering logic in frontend App.tsx. - Replaced flatMap and array spread with direct string concatenation. - Avoids intermediate array allocations and string joining to reduce main thread blocking on keystrokes. --- .jules/bolt.md | 3 + frontend/src/App.tsx | 1023 +++++++++++++++++++++++------------------- 2 files changed, 558 insertions(+), 468 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index b45f9caa..aea6c8ef 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -60,3 +60,6 @@ Optimized metric route processing to O(N) by creating a mapping of routes direct ## 2026-06-25 - Avoid Map allocations in frontend ERD loops and mutate asyncpg records in-place **Learning:** The frontend `snapshotToGraph` iterates over thousands of columns to generate the graph, so repeated lookups and redundant collection assignments increase GC pressure. Backend snapshot column dictionaries are freshly instantiated for the payload, so `add_column_examples` can safely fill missing fields in place. **Action:** Reuse existing collections while aggregating relational data, create `Map`/`Set` entries only on first use, and check for missing example fields before calling expensive inference helpers. +## 2026-07-04 - Prevent GC pressure in high-frequency React hooks +**Learning:** In frontend performance, using array spread syntax, `flatMap`, and `join()` inside operations that run on every keystroke (like search filters) over large collections (like ERD nodes and columns) causes severe garbage collection pressure and main thread blocking due to excessive intermediate array allocations. +**Action:** Use direct string concatenation or iterative string building instead of functional array chaining when filtering large datasets during high-frequency renders. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e99700c5..f5fc469b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -66,7 +66,11 @@ const TERMINAL_SNAPSHOT_STATUSES = new Set([ "not_found", ]); -const SUPPORTED_DSN_PROTOCOLS = new Set(["postgres:", "postgresql:", "snowflake:"]); +const SUPPORTED_DSN_PROTOCOLS = new Set([ + "postgres:", + "postgresql:", + "snowflake:", +]); type CurrentUser = { subject: string; @@ -151,7 +155,9 @@ export default function App() { const [shareLinkError, setShareLinkError] = useState(null); const [editingEdge, setEditingEdge] = useState(null); - const [editingNode, setEditingNode] = useState | null>(null); + const [editingNode, setEditingNode] = useState | null>( + null, + ); const [isEditTableModalOpen, setIsEditTableModalOpen] = useState(false); const [isAddTableModalOpen, setIsAddTableModalOpen] = useState(false); const [newTableName, setNewTableName] = useState(""); @@ -182,17 +188,20 @@ export default function App() { if (!normalizedNodeSearch) return new Set(); const matches = new Set(); for (const node of nodes) { - const haystack = [ - node.data.title, - node.data.comment ?? "", - ...node.data.columns.flatMap((column) => [ - column.column_name, - column.data_type, - column.column_comment ?? "", - ]), - ] - .join(" ") - .toLocaleLowerCase(); + // ⚡ Bolt: Use direct string concatenation instead of array spread/flatMap/join to prevent severe GC pressure + // during high-frequency text searches across large ERDs. + let haystack = ( + node.data.title + + " " + + (node.data.comment ?? "") + ).toLocaleLowerCase(); + for (const column of node.data.columns) { + haystack += " " + column.column_name.toLocaleLowerCase(); + haystack += " " + column.data_type.toLocaleLowerCase(); + if (column.column_comment) { + haystack += " " + column.column_comment.toLocaleLowerCase(); + } + } if (haystack.includes(normalizedNodeSearch)) { matches.add(node.id); } @@ -314,7 +323,11 @@ export default function App() { getSnapshot(snapshotId) .then((s) => { setSnapshot(s); - if (s.status === "succeeded" || s.status === "failed" || s.status === "not_found") { + if ( + s.status === "succeeded" || + s.status === "failed" || + s.status === "not_found" + ) { clearInterval(timer); if (selectedProjectId) { listSnapshots(selectedProjectId) @@ -368,9 +381,7 @@ export default function App() { // ⚡ Bolt: Removed nodesById Map creation inside useMemo which iterates over all nodes and allocates memory. // Using nodes.find() for single lookups is O(N) but avoids Map construction overhead, providing ~10x speedup and reducing GC pressure. const cardinalityNode = useMemo(() => { - return ( - nodes.find((n) => n.id === cardinalityTableId) ?? nodes[0] ?? null - ); + return nodes.find((n) => n.id === cardinalityTableId) ?? nodes[0] ?? null; }, [cardinalityTableId, nodes]); const cardinalityColumns = useMemo(() => { if (!cardinalityNode) return []; @@ -381,11 +392,7 @@ export default function App() { cardinalityDistinctCounts[column.column_name] ?? "", ), })); - }, [ - cardinalityColumnSelections, - cardinalityDistinctCounts, - cardinalityNode, - ]); + }, [cardinalityColumnSelections, cardinalityDistinctCounts, cardinalityNode]); const cardinalityRecommendations = useMemo( () => buildIndexRecommendations({ @@ -393,7 +400,11 @@ export default function App() { rowCount: cardinalityRowCountNumber, columns: cardinalityColumns, }), - [cardinalityColumns, cardinalityNode?.data.title, cardinalityRowCountNumber], + [ + cardinalityColumns, + cardinalityNode?.data.title, + cardinalityRowCountNumber, + ], ); const appliedCardinalityIndexes = useMemo( () => cardinalityNode?.data.indexes ?? [], @@ -405,7 +416,8 @@ export default function App() { const columns = new Set(); for (const index of appliedCardinalityIndexes) { if (index.index_name) names.add(index.index_name); - if (index.columns && index.columns.length > 0) columns.add(index.columns.join(",")); + if (index.columns && index.columns.length > 0) + columns.add(index.columns.join(",")); } return { names, columns }; }, [appliedCardinalityIndexes]); @@ -502,11 +514,14 @@ export default function App() { } } - const onNodeDoubleClick = useCallback((event: React.MouseEvent, node: Node) => { - event.preventDefault(); - setEditingNode(node as Node); - setIsEditTableModalOpen(true); - }, []); + const onNodeDoubleClick = useCallback( + (event: React.MouseEvent, node: Node) => { + event.preventDefault(); + setEditingNode(node as Node); + setIsEditTableModalOpen(true); + }, + [], + ); const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge) => { event.preventDefault(); @@ -621,7 +636,11 @@ export default function App() { } function onDownloadMermaid() { - downloadText("pg-erd-diagram.mermaid", exportMermaid(nodes, edges), "text/plain"); + downloadText( + "pg-erd-diagram.mermaid", + exportMermaid(nodes, edges), + "text/plain", + ); } function onRelDelete() { @@ -670,10 +689,7 @@ export default function App() { })); } - function onCardinalityDistinctCountChange( - columnName: string, - value: string, - ) { + function onCardinalityDistinctCountChange(columnName: string, value: string) { if (!/^\d*$/.test(value)) return; setCardinalityDistinctCounts((prev) => ({ ...prev, @@ -703,7 +719,8 @@ export default function App() { const recColumns = recommendation.columns?.join(",") ?? ""; if ( - (recommendation.index_name && appliedIndexNames.has(recommendation.index_name)) || + (recommendation.index_name && + appliedIndexNames.has(recommendation.index_name)) || (recColumns && appliedColumns.has(recColumns)) ) { return currentNodes; @@ -765,7 +782,12 @@ export default function App() { } function onDeleteBusinessGroup(groupId: string) { - if (!window.confirm("이 그룹을 삭제하면 포함된 모든 테이블에서 그룹 지정이 해제됩니다. 정말로 삭제하시겠습니까?")) return; + if ( + !window.confirm( + "이 그룹을 삭제하면 포함된 모든 테이블에서 그룹 지정이 해제됩니다. 정말로 삭제하시겠습니까?", + ) + ) + return; setBusinessGroups((groups) => groups.filter((group) => group.id !== groupId), ); @@ -784,7 +806,6 @@ export default function App() { ); } - function onDeleteTable() { if (!editingNode) return; if (!window.confirm("정말로 이 테이블을 삭제하시겠습니까?")) return; @@ -793,7 +814,11 @@ export default function App() { setNodes((nds) => nds.filter((n) => n.id !== editingNode.id)); // Remove connected edges - setEdges((eds) => eds.filter((e) => e.source !== editingNode.id && e.target !== editingNode.id)); + setEdges((eds) => + eds.filter( + (e) => e.source !== editingNode.id && e.target !== editingNode.id, + ), + ); setIsEditTableModalOpen(false); setEditingNode(null); @@ -810,7 +835,9 @@ export default function App() { if (!title.trim()) return; // Parse columns from formData - const updatedColumns: Array["data"]["columns"][number]> = []; + const updatedColumns: Array< + Node["data"]["columns"][number] + > = []; for (let i = 0; i < editingNode.data.columns.length; i++) { const colName = formData.get(`col_name_${i}`) as string; if (colName === null) continue; // Deleted column @@ -840,13 +867,13 @@ export default function App() { columns: updatedColumns, badges: { ...n.data.badges, - pk: updatedColumns.some(c => c.is_pk) - } - } + pk: updatedColumns.some((c) => c.is_pk), + }, + }, }; } return n; - }) + }), ); setIsEditTableModalOpen(false); @@ -858,7 +885,6 @@ export default function App() { setEditingNode(null); } - function onAddTableSubmit() { if (!newTableName.trim()) return; const newId = `new_table_${Date.now()}`; @@ -917,7 +943,9 @@ export default function App() { const connectionDsn = dsnInputRef.current?.value.trim() ?? ""; if (!nextConnectionName || !connectionDsn) return; if (!isSupportedConnectionDsn(connectionDsn)) { - setError("Connection DSN must use postgresql://, postgres://, or snowflake:// with a host."); + setError( + "Connection DSN must use postgresql://, postgres://, or snowflake:// with a host.", + ); if (dsnInputRef.current) { dsnInputRef.current.value = ""; } @@ -962,12 +990,7 @@ export default function App() { if (isAuthLoading) { return ( -
+

pg-erd-cloud

Authenticating…

@@ -978,7 +1001,9 @@ export default function App() { return (

Authentication required

-

{authError ?? "Sign in before managing database metadata."}

+

+ {authError ?? "Sign in before managing database metadata."} +

); } @@ -999,7 +1024,11 @@ export default function App() { + + {createProjectHint ? ( + + {createProjectHint} + + ) : null} + + +
+ +
+ + -
- + {connections.map((c) => ( + + ))} + + + +
+ + setConnName(e.target.value)} + placeholder="name" + /> + + setIsDsnPresent(Boolean(e.currentTarget.value.trim())) + } + placeholder="postgresql://... or snowflake://..." + aria-label="Connection DSN" + /> + + {createConnectionHint ? ( + + {createConnectionHint} + + ) : null} +
+ +
+ + setSchemaFilter(e.target.value)} + placeholder="public" + /> +
-
- -
- setProjectName(e.target.value)} - /> -
- {createProjectHint ? ( - - {createProjectHint} - - ) : null} -
- -
- -
- - -
- -
- - setConnName(e.target.value)} - placeholder="name" - /> - - setIsDsnPresent(Boolean(e.currentTarget.value.trim())) - } - placeholder="postgresql://... or snowflake://..." - aria-label="Connection DSN" - /> - - {createConnectionHint ? ( - - {createConnectionHint} - - ) : null} -
- -
- - setSchemaFilter(e.target.value)} - placeholder="public" - /> -
- - - {createSnapshotHint ? ( - - {createSnapshotHint} - - ) : null} - -
- Snapshot: {snapshot?.status || "—"} - {snapshot?.error_message ? ( -
- {String(snapshot.error_message)} + {createSnapshotHint ? ( + + {createSnapshotHint} + + ) : null} + +
+ Snapshot: {snapshot?.status || "—"} + {snapshot?.error_message ? ( +
+ {String(snapshot.error_message)} +
+ ) : null}
- ) : null} -
- {error ? ( -
- {error} -
- ) : null} + {error ? ( +
+ {error} +
+ ) : null} ) : (
@@ -1182,7 +1218,10 @@ export default function App() {
{activeView === "dashboard" ? ( -
+

대시보드

@@ -1208,7 +1247,10 @@ export default function App() {
-
+

최근 프로젝트

) : ( -
아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요.
+
+ 아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요. +
)}
-
+

최근 다이어그램

-
+
이름 연결 동작
{projects.map((project) => ( -
+
{project.project_name} - {project.project_space_uuid === selectedProjectId ? connections.length : "선택 후 표시"} + + {project.project_space_uuid === selectedProjectId + ? connections.length + : "선택 후 표시"} +
@@ -1311,7 +1375,11 @@ export default function App() {

다이어그램

-

{selectedProject ? `${selectedProject.project_name} 프로젝트의 스냅샷` : "프로젝트를 선택하세요."}

+

+ {selectedProject + ? `${selectedProject.project_name} 프로젝트의 스냅샷` + : "프로젝트를 선택하세요."} +

) : (
-
- - - - - - - - - + + + + + + + + + +
+ {[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")} +
+
+ + { + reactFlowRef.current = instance; + }} > - IMG - - + + )} +
+ )} + + 0} + shareLinkUrl={shareLinkUrl} + isCreatingShareLink={isCreatingShareLink} + isShareLinkCopied={isShareLinkCopied} + shareLinkError={shareLinkError} + canCreateShareLink={Boolean(selectedProjectId)} + onCloseExport={onCloseExport} + onCopyExportDdl={onCopyExportDdl} + onCreateShareLink={onCreateShareLink} + onCopyShareLink={onCopyShareLink} + /> + + + + + + - UML - - -
- {[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")} -
-
+ parsePositiveInteger={parsePositiveInteger} + calculateCardinalityRatio={calculateCardinalityRatio} + formatPercent={formatPercent} + strengthLabel={strengthLabel} + /> - { - reactFlowRef.current = instance; - }} - > - - - - - - {nodes.length === 0 && ( -
- {isSnapshotPending ? ( - <> - + + + +
)}
@@ -1613,7 +1695,8 @@ function DiagramTable({ if (!snapshots.length) { return (
- 아직 다이어그램 스냅샷이 없습니다. 편집기에서 데이터베이스를 역공학해 시작하세요. + 아직 다이어그램 스냅샷이 없습니다. 편집기에서 데이터베이스를 역공학해 + 시작하세요.
); } @@ -1627,7 +1710,11 @@ function DiagramTable({ 동작 {snapshots.map((item, index) => ( -
+
ERD_{item.schema_filter || "all"}_{index + 1}