diff --git a/src/components/notifications/NotificationListItem.tsx b/src/components/notifications/NotificationListItem.tsx index 0ea8890a..8d90450b 100644 --- a/src/components/notifications/NotificationListItem.tsx +++ b/src/components/notifications/NotificationListItem.tsx @@ -1,16 +1,17 @@ +import { memo } from 'react'; import { getNotificationLabel } from './notificationTypes'; 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; } -export function NotificationListItem({ +export const NotificationListItem = memo(function NotificationListItem({ notification, - busyId, + isBusy, onOpenNotification, onDelete, }: NotificationListItemProps) { @@ -20,7 +21,7 @@ export function NotificationListItem({ type="button" className="notification-item__content" onClick={() => void onOpenNotification(notification)} - disabled={busyId === notification.id} + disabled={isBusy} >
{getNotificationLabel(notification)} @@ -36,10 +37,10 @@ export 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); + }); +});