Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@
## 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.
## 2025-02-12 - Memoizing inline array maps
**Learning:** Inline mapping of arrays inside JSX in large React components causes O(N) recalculation on every render when unrelated parent states change.
**Action:** Wrap inline JSX elements that map over potentially large arrays (e.g., `emails.map`) in a `useMemo` hook with specific dependencies, rather than computing them directly inside the return statement.
60 changes: 60 additions & 0 deletions frontend/src/components/EmailList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,66 @@ describe("EmailList", () => {
expect(selectedThread?.className).toContain("min-h-20");
});

it("updates memoized selection and click handling after prop changes", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(
jsonResponse({
emails: [
{
id: 21,
sender: "기획팀",
subject: "첫 번째 메일",
date: "2026-05-11T09:30:00Z",
snippet: "첫 번째 메일입니다.",
},
{
id: 22,
sender: "개발팀",
subject: "두 번째 메일",
date: "2026-05-11T10:30:00Z",
snippet: "두 번째 메일입니다.",
},
],
}),
),
);
const firstSelect = vi.fn();
const secondSelect = vi.fn();
vi.stubGlobal("fetch", fetchMock);

container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<EmailList onSelectEmail={firstSelect} selectedEmailId={21} />);
});
await flushAsyncWork();

let selectedThread = container.querySelector<HTMLButtonElement>('button[aria-current="true"]');
expect(selectedThread?.textContent).toContain("첫 번째 메일");

await act(async () => {
root?.render(<EmailList onSelectEmail={secondSelect} selectedEmailId={22} />);
});

selectedThread = container.querySelector<HTMLButtonElement>('button[aria-current="true"]');
expect(selectedThread?.textContent).toContain("두 번째 메일");

const firstThread = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.includes("첫 번째 메일"),
);
expect(firstThread).toBeDefined();

await act(async () => {
firstThread?.click();
});

expect(secondSelect).toHaveBeenCalledWith(21);
expect(firstSelect).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("uses the missing-title fallback for blank email subjects", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(
Expand Down
24 changes: 15 additions & 9 deletions frontend/src/components/EmailList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState, memo } from 'react';
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
import { apiClient } from '@/lib/api-client';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
Expand Down Expand Up @@ -173,6 +173,19 @@ export function EmailList({
};
const searchBusy = isSearching || loading;

// ⚡ Bolt: Wrap Email list in useMemo to prevent O(N) re-renders
// 🎯 Why: Mapping over potentially large lists of emails blocks the main thread during unrelated state updates.
const emailListContent = useMemo(() => {
return emails.map((email: EmailItem) => (
<EmailListItemComponent
key={email.id}
email={email}
selected={selectedEmailId === email.id}
onSelectEmail={onSelectEmail}
/>
));
}, [emails, selectedEmailId, onSelectEmail]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div className="flex h-full min-h-0 w-full flex-col border-r border-border/80 bg-card/95">
<div className="border-b border-border/80 bg-gradient-to-br from-card via-card to-primary/5 p-4">
Expand Down Expand Up @@ -259,14 +272,7 @@ export function EmailList({
<p className="mt-1 text-xs leading-5">{folderCopy.emptyBody}</p>
</div>
) : (
emails.map((email: EmailItem) => (
<EmailListItemComponent
key={email.id}
email={email}
selected={selectedEmailId === email.id}
onSelectEmail={onSelectEmail}
/>
))
emailListContent
)}
</div>
</ScrollArea>
Expand Down
Loading