From cad17da3f5d5babe1a00c02e3c91e25d26d37229 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:45:23 +0000 Subject: [PATCH 1/2] perf(ui): add React.memo to NotificationListItem Co-authored-by: ClarusIubar <101549899+ClarusIubar@users.noreply.github.com> --- .jules/bolt.md | 3 +++ src/components/notifications/NotificationListItem.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .jules/bolt.md diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 00000000..f72767b3 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2024-06-13 - [React.memo in Mapped Lists] +**Learning:** React.memo is highly effective for list items mapped in a parent component, provided that the callback props are stabilized (e.g., using `useEventCallback` in upper-level hooks). Replacing `new Map(array.map(...))` with imperative loops for small arrays is considered an unreadable micro-optimization. +**Action:** Prioritize identifying unmemoized list item components before looking for low-level memory allocation micro-optimizations, as React reconciliation is often the primary bottleneck in UI performance. diff --git a/src/components/notifications/NotificationListItem.tsx b/src/components/notifications/NotificationListItem.tsx index 0ea8890a..51926447 100644 --- a/src/components/notifications/NotificationListItem.tsx +++ b/src/components/notifications/NotificationListItem.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { getNotificationLabel } from './notificationTypes'; import type { NotificationItem } from './notificationTypes'; @@ -8,7 +9,8 @@ interface NotificationListItemProps { onDelete: (event: React.MouseEvent, notificationId: string) => Promise; } -export function NotificationListItem({ +// Optimizes performance by preventing unnecessary re-renders of list items in notification panel +export const NotificationListItem = memo(function NotificationListItem({ notification, busyId, onOpenNotification, @@ -42,4 +44,4 @@ export function NotificationListItem({ ); -} +}); From 778f7ba107afbe4a95b92425c362251f685771e1 Mon Sep 17 00:00:00 2001 From: ClarusIubar Date: Sat, 13 Jun 2026 13:29:21 +0900 Subject: [PATCH 2/2] perf: stabilize notification item renders --- .jules/bolt.md | 3 - .../notifications/NotificationListItem.tsx | 9 +-- .../notifications/NotificationPanel.tsx | 2 +- .../useNotificationPanelActions.ts | 13 +-- test/unit/notification-list-render.test.tsx | 81 +++++++++++++++++++ 5 files changed, 93 insertions(+), 15 deletions(-) delete mode 100644 .jules/bolt.md create mode 100644 test/unit/notification-list-render.test.tsx diff --git a/.jules/bolt.md b/.jules/bolt.md deleted file mode 100644 index f72767b3..00000000 --- a/.jules/bolt.md +++ /dev/null @@ -1,3 +0,0 @@ -## 2024-06-13 - [React.memo in Mapped Lists] -**Learning:** React.memo is highly effective for list items mapped in a parent component, provided that the callback props are stabilized (e.g., using `useEventCallback` in upper-level hooks). Replacing `new Map(array.map(...))` with imperative loops for small arrays is considered an unreadable micro-optimization. -**Action:** Prioritize identifying unmemoized list item components before looking for low-level memory allocation micro-optimizations, as React reconciliation is often the primary bottleneck in UI performance. diff --git a/src/components/notifications/NotificationListItem.tsx b/src/components/notifications/NotificationListItem.tsx index 51926447..8d90450b 100644 --- a/src/components/notifications/NotificationListItem.tsx +++ b/src/components/notifications/NotificationListItem.tsx @@ -4,15 +4,14 @@ import type { NotificationItem } from './notificationTypes'; interface NotificationListItemProps { notification: NotificationItem; - busyId: string | null; + isBusy: boolean; onOpenNotification: (notification: NotificationItem) => Promise; onDelete: (event: React.MouseEvent, notificationId: string) => Promise; } -// Optimizes performance by preventing unnecessary re-renders of list items in notification panel export const NotificationListItem = memo(function NotificationListItem({ notification, - busyId, + isBusy, onOpenNotification, onDelete, }: NotificationListItemProps) { @@ -22,7 +21,7 @@ export const NotificationListItem = memo(function NotificationListItem({ type="button" className="notification-item__content" onClick={() => void onOpenNotification(notification)} - disabled={busyId === notification.id} + disabled={isBusy} >
{getNotificationLabel(notification)} @@ -38,7 +37,7 @@ export const NotificationListItem = memo(function NotificationListItem({ className="notification-item__delete" aria-label="알림 삭제" onClick={(event) => void onDelete(event, notification.id)} - disabled={busyId === notification.id} + disabled={isBusy} > × diff --git a/src/components/notifications/NotificationPanel.tsx b/src/components/notifications/NotificationPanel.tsx index 35008d07..e84db132 100644 --- a/src/components/notifications/NotificationPanel.tsx +++ b/src/components/notifications/NotificationPanel.tsx @@ -43,7 +43,7 @@ export function NotificationPanel({ diff --git a/src/components/notifications/useNotificationPanelActions.ts b/src/components/notifications/useNotificationPanelActions.ts index ec869bd4..3e14dc22 100644 --- a/src/components/notifications/useNotificationPanelActions.ts +++ b/src/components/notifications/useNotificationPanelActions.ts @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useEventCallback } from '../../hooks/useEventCallback'; import type { NotificationItem } from './notificationTypes'; interface UseNotificationPanelActionsParams { @@ -18,7 +19,7 @@ export function useNotificationPanelActions({ const [busyAll, setBusyAll] = useState(false); const [error, setError] = useState(null); - async function handleOpenNotification(notification: NotificationItem) { + const handleOpenNotification = useEventCallback(async (notification: NotificationItem) => { try { setBusyId(notification.id); setError(null); @@ -29,9 +30,9 @@ export function useNotificationPanelActions({ } finally { setBusyId(null); } - } + }); - async function handleMarkAll() { + const handleMarkAll = useEventCallback(async () => { try { setBusyAll(true); setError(null); @@ -41,9 +42,9 @@ export function useNotificationPanelActions({ } finally { setBusyAll(false); } - } + }); - async function handleDelete(event: React.MouseEvent, notificationId: string) { + const handleDelete = useEventCallback(async (event: React.MouseEvent, notificationId: string) => { event.stopPropagation(); try { setBusyId(notificationId); @@ -54,7 +55,7 @@ export function useNotificationPanelActions({ } finally { setBusyId(null); } - } + }); return { busyId, diff --git a/test/unit/notification-list-render.test.tsx b/test/unit/notification-list-render.test.tsx new file mode 100644 index 00000000..04ef5335 --- /dev/null +++ b/test/unit/notification-list-render.test.tsx @@ -0,0 +1,81 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { NotificationPanel } from '../../src/components/notifications/NotificationPanel'; +import type { NotificationItem, NotificationPanelActions } from '../../src/components/notifications/notificationTypes'; + +const labelRenderCounts = vi.hoisted(() => new Map()); + +vi.mock('../../src/components/notifications/notificationTypes', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getNotificationLabel: vi.fn((notification: NotificationItem) => { + labelRenderCounts.set(notification.id, (labelRenderCounts.get(notification.id) ?? 0) + 1); + return notification.type; + }), + }; +}); + +function createNotification(id: string): NotificationItem { + return { + id, + type: 'review-comment', + title: `title-${id}`, + body: `body-${id}`, + createdAt: '2026-06-13T00:00:00Z', + isRead: false, + reviewId: `review-${id}`, + commentId: `comment-${id}`, + routeId: null, + actorName: null, + }; +} + +function createActions(busyId: string | null): NotificationPanelActions { + return { + busyAll: false, + busyId, + error: null, + handleDelete: stableHandlers.handleDelete, + handleMarkAll: stableHandlers.handleMarkAll, + handleOpenNotification: stableHandlers.handleOpenNotification, + }; +} + +const stableHandlers = { + handleDelete: vi.fn(async () => undefined), + handleMarkAll: vi.fn(async () => undefined), + handleOpenNotification: vi.fn(async () => undefined), +}; + +describe('NotificationPanel item render stability', () => { + it('does not re-render inactive notification items when another item becomes busy', () => { + labelRenderCounts.clear(); + const notifications = [createNotification('n-1'), createNotification('n-2')]; + + const { rerender } = render( + , + ); + + expect(labelRenderCounts.get('n-1')).toBe(1); + expect(labelRenderCounts.get('n-2')).toBe(1); + + rerender( + , + ); + + expect(labelRenderCounts.get('n-1')).toBe(2); + expect(labelRenderCounts.get('n-2')).toBe(1); + }); +});