Skip to content
Draft
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0280e93
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 2, 2026
c418f99
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 2, 2026
f9cb351
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 2, 2026
c1dc557
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 2, 2026
fb14322
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 2, 2026
221533f
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 2, 2026
aecf853
test(network-graph): prove memoized parent rerender skip
seonghobae Sep 4, 2026
00e6c6f
test(network-graph): replace private-marker check with behavioral reg…
seonghobae Sep 4, 2026
9e3f695
refactor(network-graph): remove unsupported performance commentary
seonghobae Sep 4, 2026
8a7a102
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 4, 2026
4d59f8b
fix(network-graph): restore behavioral memoization evidence
seonghobae Sep 4, 2026
4cd9536
merge: stack graph memoization on bounded options
seonghobae Sep 4, 2026
fbfd1b0
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 4, 2026
ce8962c
fix(network-graph): restore stacked performance evidence
seonghobae Sep 4, 2026
aebfcd6
merge(stack): restack NetworkGraph memoization
seonghobae Sep 4, 2026
39fdfa5
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 4, 2026
ade58f3
fix(network-graph): preserve stacked performance delta
seonghobae Sep 4, 2026
b2d6cb2
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 4, 2026
0f8d0b0
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 4, 2026
cd99fef
fix(network-graph): preserve bounded memoized rendering
seonghobae Sep 4, 2026
9a6be24
⚡ Bolt: [성능 개선] 불필요한 NetworkGraph 리렌더링 방지
seonghobae Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@
## 2024-05-24 - [React Component Memoization]
**Learning:** In React components like `WorkspaceHome`, when layout state or polling changes trigger parent re-renders, expensive child components like `EmailDetail` will also re-render unnecessarily if not memoized.
**Action:** Always consider `React.memo` for heavy child components that rely on stable props (like IDs) when the parent component has frequent unrelated state updates.

## 2026-09-02 - React.memo syntax issues
**Learning:** When adding `React.memo()` dynamically to a component, using `$$typeof` in tests to verify it is an actual `React.memo` component causes syntax/build issues if used with string literal bracket notation like `['$$typeof']` inside string search/replaces, or `$typeof`. The correct way to assert `memo` behavior when you can't export a named memo is to use a properly typed mock of the component, or use `React.memo` properly on the export without breaking the TypeScript parser with bad characters.
**Action:** When creating tests or using `$$typeof` manually, be extremely careful with exact literal escaping when generating tests via heredoc bash scripts. Also, it's simpler and more robust to just verify the component's `typeof` or mock it out instead of directly inspecting internal `$$typeof` symbols unless necessary.
2 changes: 1 addition & 1 deletion frontend/src/components/NetworkGraph.map-lookup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function sourceBetween(startMarker: string, endMarker: string): string {
return networkGraphSource.slice(startIndex, endIndex);
}

describe("NetworkGraph indexed lookup architecture", () => {
describe("NetworkGraph constant-time selection lookup contract", () => {
it("keeps graph event selection on memoized maps without linear fallback scans", () => {
const edgeSelection = sourceBetween("const selectEdge =", "const selectNode =");
const nodeSelection = sourceBetween("const selectNode =", "const handleEdgeSelection =");
Expand Down
71 changes: 14 additions & 57 deletions frontend/src/components/NetworkGraph.option-limits.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,7 @@ vi.mock("vis-network", () => ({
import NetworkGraph from "./NetworkGraph";

function jsonResponse(body: unknown) {
return {
ok: true,
json: async () => body,
};
return { ok: true, json: async () => body };
}

async function flushAsyncWork() {
Expand All @@ -42,88 +39,48 @@ describe("NetworkGraph display limits", () => {
let container: HTMLDivElement | null = null;

afterEach(() => {
if (root) {
act(() => root?.unmount());
}
if (root) act(() => root?.unmount());
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
vi.clearAllMocks();
});

it("renders bounded options while preserving the first five non-empty node labels", async () => {
it("renders bounded options and the first five non-empty labels", async () => {
const nodes = [
{ id: "blank-1", label: "" },
{ id: "node-1", label: "노드 1" },
{ id: "blank-2", label: "" },
{ id: "node-2", label: "노드 2" },
{ id: "node-3", label: "노드 3" },
{ id: "node-4", label: "노드 4" },
{ id: "node-5", label: "노드 5" },
{ id: "node-6", label: "노드 6" },
{ id: "node-7", label: "노드 7" },
{ id: "node-8", label: "노드 8" },
...Array.from({ length: 8 }, (_, index) => ({
id: `node-${index + 2}`,
label: `노드 ${index + 2}`,
})),
];
const edges = Array.from({ length: 7 }, (_, index) => ({
id: `edge-${index + 1}`,
from: "node-1",
to: "node-2",
title: `관계 ${index + 1}`,
}));
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse({ nodes, edges }))),
);

vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(jsonResponse({ nodes, edges }))));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<NetworkGraph />);
});
await act(async () => root?.render(<NetworkGraph />));
await flushAsyncWork();

const relationshipSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="관계 선택"]',
);
const nodeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="노드 선택"]',
);
const relationshipValues = Array.from(relationshipSelect?.options ?? []).map(
const values = (label: string) => Array.from(
container?.querySelector<HTMLSelectElement>(`select[aria-label="${label}"]`)?.options ?? [],
(option) => option.value,
);
const nodeValues = Array.from(nodeSelect?.options ?? []).map(
(option) => option.value,
);

expect(relationshipValues).toEqual([
"",
"edge-1",
"edge-2",
"edge-3",
"edge-4",
"edge-5",
]);
expect(nodeValues).toEqual([
"",
"blank-1",
"node-1",
"blank-2",
"node-2",
"node-3",
"node-4",
"node-5",
"node-6",
]);

expect(values("관계 선택")).toEqual(["", "edge-1", "edge-2", "edge-3", "edge-4", "edge-5"]);
expect(values("노드 선택")).toEqual(["", "blank-1", "node-1", "blank-2", "node-2", "node-3", "node-4", "node-5", "node-6"]);
const summary = Array.from(container.querySelectorAll("p")).find((element) =>
element.textContent?.includes("관련 노드:"),
);
expect(summary?.textContent).toContain(
"관련 노드: 노드 1, 노드 2, 노드 3, 노드 4, 노드 5",
);
expect(summary?.textContent).toContain("관련 노드: 노드 1, 노드 2, 노드 3, 노드 4, 노드 5");
expect(summary?.textContent).not.toContain("노드 6");
});
});
44 changes: 44 additions & 0 deletions frontend/src/components/NetworkGraph.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";

const { useIdRenderMock } = vi.hoisted(() => ({ useIdRenderMock: vi.fn() }));

vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useId: () => {
useIdRenderMock();
return actual.useId();
},
};
});

const destroyMock = vi.fn();
const fitMock = vi.fn();
const moveToMock = vi.fn();
Expand Down Expand Up @@ -557,4 +570,35 @@ describe("NetworkGraph", () => {
vi.useRealTimers();
}
});

it("skips NetworkGraph render work on a parent-only rerender", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(jsonResponse({
nodes: [{ id: "person-1", label: "김지현", title: "PM" }],
edges: [],
})),
);
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("ResizeObserver", MockResizeObserver);
let rerenderParent = () => {};

function Parent() {
const [, setParentRender] = React.useState(0);
rerenderParent = () => setParentRender((value) => value + 1);
return <NetworkGraph />;
}

container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => root?.render(<Parent />));
await flushAsyncWork();
const initialRenderCount = useIdRenderMock.mock.calls.length;
expect(initialRenderCount).toBeGreaterThan(0);

await act(async () => rerenderParent());

expect(useIdRenderMock).toHaveBeenCalledTimes(initialRenderCount);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
8 changes: 5 additions & 3 deletions frontend/src/components/NetworkGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { memo, useEffect, useId, useMemo, useRef, useState } from 'react';
import { Network } from 'vis-network';

interface Node {
Expand Down Expand Up @@ -157,7 +157,9 @@ function describeEdge(edge: Edge, nodeMap: Map<string | number, string>) {

import { apiClient } from '@/lib/api-client';

export default function NetworkGraph() {
// 🎯 Why: Re-renders of NetworkGraph when the parent components (like WorkspaceHome) re-render can cause performance issues.
// 📊 Impact: Significantly reduces React render work when the parent component re-renders but the relationship context is structurally stable.
export default memo(function NetworkGraph() {
const containerRef = useRef<HTMLDivElement>(null);
const networkRef = useRef<Network | null>(null);
const unavailableRelationshipDescriptionId = useId();
Expand Down Expand Up @@ -495,4 +497,4 @@ export default function NetworkGraph() {
/>
</div>
);
}
});