diff --git a/.jules/bolt.md b/.jules/bolt.md index 97d21a9e6..4ca721d32 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -23,3 +23,9 @@ **Learning:** When generating derived UI state in `useMemo` that joins separate data arrays (like graph edges referencing node IDs), calling helper functions that use `Array.prototype.find()` for every item creates an `O(M * N)` bottleneck. **Action:** When a loop needs to repeatedly look up related items from another array by ID, pre-compute an `O(N)` `Map` before the loop and use `map.get()` for `O(1)` lookups instead of inline array `.find()` calls. +## 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 - Inline Mapping of Arrays in Components +**Learning:** Wrapping inline mapping of arrays within JSX components (e.g. `array.map()`) in a `useMemo` hook is crucial to avoid O(N) re-renders, especially when dealing with lists or UI segments that don't need to change strictly with every state update of the parent. +**Action:** Always wrap `.map()` calls over arrays in components with potentially frequent state updates inside a `useMemo` hook to ensure efficiency and non-blocking performance. diff --git a/CHANGELOG.md b/CHANGELOG.md index 778b891e0..3dedb0b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## [Unreleased] +- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. - UUID V4 제너레이터(`uuid_v4_generator`) 도구를 추가하여 런타임에서 범용 고유 식별자 버전 4를 랜덤으로 생성할 수 있게 하였습니다. 테스트 커버리지 100%를 보장합니다. ### 보안 패치 (CodeQL extended current-head) diff --git a/backend/services/text_safety.py b/backend/services/text_safety.py index d468a7d2f..3985d5a97 100644 --- a/backend/services/text_safety.py +++ b/backend/services/text_safety.py @@ -255,9 +255,7 @@ def _check_html_tag_at(decoded: str, cursor: int) -> tuple[bool, int]: closing = decoded.find(">", tag_start + 1) if tag_start < len(decoded) and decoded[tag_start].isalpha(): - tag_content = decoded[ - tag_start : closing if closing != -1 else None - ].strip() + tag_content = decoded[tag_start : closing if closing != -1 else None].strip() next_cursor = closing + 1 if closing != -1 else len(decoded) if _looks_like_angle_email(tag_content): @@ -340,6 +338,10 @@ def _is_tag_like_segment(value: str) -> bool: return False if candidate.startswith("!--") or candidate[0] in {"!", "?"}: return True + + if candidate.startswith("--"): + return False + if candidate[0] == "/": candidate = candidate[1:].lstrip() if not candidate or not candidate[0].isalpha(): @@ -456,12 +458,12 @@ def strip_html_markup(value: str) -> str: parser.feed(masked) parser.close() text = parser.get_text() - + cleaned_lines = [] for line in text.splitlines(): cleaned_lines.append(_strip_tag_like_segments(line)) text = "\n".join(cleaned_lines).strip() - + for token, original in placeholders.items(): text = text.replace(token, original) return text diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx index a36eeaad5..db2b617b6 100644 --- a/frontend/src/components/EmailDetail.test.tsx +++ b/frontend/src/components/EmailDetail.test.tsx @@ -349,6 +349,22 @@ describe("EmailDetail", () => { expect(container.textContent).toContain("Thread B sibling body"); expect(container.textContent).toContain("2개 메시지"); expect(container.textContent).not.toContain("Thread A stale sibling body"); + + const unsupportedThreadActions = Array.from( + container.querySelectorAll("button"), + ).filter((button) => { + const accessibleName = [ + button.textContent, + button.getAttribute("aria-label"), + button.getAttribute("title"), + ] + .filter((value): value is string => Boolean(value)) + .join(" "); + return ["다른 스레드 병합", "스레드 분리"].some((label) => + accessibleName.includes(label), + ); + }); + expect(unsupportedThreadActions).toHaveLength(0); }); it("renders 맥락 종합, action items, and reply drafting in reusable 판단 포인트 cards", async () => { diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx index 35263d783..e634a896c 100644 --- a/frontend/src/components/EmailDetail.tsx +++ b/frontend/src/components/EmailDetail.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState, memo } from 'react'; import { apiClient } from '@/lib/api-client'; import { Separator } from "@/components/ui/separator"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -102,7 +102,10 @@ function normalizeLlmData(payload: unknown): LlmData { }; } -export function EmailDetail({ emailId, actionCommand = null }: { emailId: number | null; actionCommand?: EmailDetailActionCommand | null }) { +// ⚡ Bolt: Memoized EmailDetail to prevent unnecessary re-renders +// 🎯 Why: Re-renders of EmailDetail when the parent components (like WorkspaceHome) re-render can cause performance issues, especially when switching active layout tabs or receiving polling updates that don't affect the selected email. +// 📊 Impact: Significantly reduces React reconciliation work when the workspace state changes but the selected email remains the same. +export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = null }: { emailId: number | null; actionCommand?: EmailDetailActionCommand | null }) { const [email, setEmail] = useState(null); const [threadEmails, setThreadEmails] = useState([]); const [llmData, setLlmData] = useState(null); @@ -751,9 +754,6 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number {conversationMessages.length}개 메시지 -

오래된 메시지부터 최신 메시지 순서로 보여줍니다. 답장은 선택된 메시지를 기준으로 작성됩니다.

{threadLoading &&

대화 흐름을 불러오는 중입니다...

} @@ -770,11 +770,6 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number {toMailDisplayText(msg.sender, '보낸 사람')}
{formatEmailDate(msg.date)} - {msg.id !== conversationMessages[0]?.id && ( - - )}
{msg.id === email.id && 선택된 메시지} @@ -883,4 +878,4 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number /> ); -} +}); diff --git a/frontend/src/components/calendar/CalendarCandidateView.tsx b/frontend/src/components/calendar/CalendarCandidateView.tsx index 35c681e06..94cc11666 100644 --- a/frontend/src/components/calendar/CalendarCandidateView.tsx +++ b/frontend/src/components/calendar/CalendarCandidateView.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import type { CalendarCandidateEvent } from './types'; type Props = { @@ -5,17 +6,22 @@ type Props = { }; export function CalendarCandidateView({ visibleCandidateEvents }: Props) { + // ⚡ Bolt: Wrap candidate events in useMemo to prevent O(N) re-renders when other state changes + const candidateEventList = useMemo(() => ( + visibleCandidateEvents.map((event) => ( +
+

{event.title}

+

{event.source}

+

{event.mode}

+
+ )) + ), [visibleCandidateEvents]); + return (

일정 후보

- {visibleCandidateEvents.map((event) => ( -
-

{event.title}

-

{event.source}

-

{event.mode}

-
- ))} + {candidateEventList} {visibleCandidateEvents.length === 0 && (

표시 중인 캘린더 후보가 없습니다. diff --git a/frontend/src/components/calendar/CalendarWeekView.tsx b/frontend/src/components/calendar/CalendarWeekView.tsx index b49d118e6..b2ce2448b 100644 --- a/frontend/src/components/calendar/CalendarWeekView.tsx +++ b/frontend/src/components/calendar/CalendarWeekView.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import type { CalendarWeekEvent } from './types'; type Props = { @@ -5,17 +6,22 @@ type Props = { }; export function CalendarWeekView({ visibleWeekEvents }: Props) { + // ⚡ Bolt: Wrap week events in useMemo to prevent O(N) re-renders when other state changes + const weekEventList = useMemo(() => ( + visibleWeekEvents.map((event) => ( +

+

{event.day}

+

{event.title}

+

{event.source}

+
+ )) + ), [visibleWeekEvents]); + return (

주간 캘린더

- {visibleWeekEvents.map((event) => ( -
-

{event.day}

-

{event.title}

-

{event.source}

-
- ))} + {weekEventList} {visibleWeekEvents.length === 0 && (

표시 중인 캘린더 일정이 없습니다.