diff --git a/frontend/src/contexts/TradeNotificationContext.tsx b/frontend/src/contexts/TradeNotificationContext.tsx index 940e337..644717a 100644 --- a/frontend/src/contexts/TradeNotificationContext.tsx +++ b/frontend/src/contexts/TradeNotificationContext.tsx @@ -266,23 +266,31 @@ export const TradeNotificationProvider: React.FC = ({ const id = makeId(signal); /** - * 이미 읽음(닫기) 처리된 알림 ID 이면: - * - allNotifications 에 이미 존재하면 isRead 상태를 건드리지 않는다. - * - 새로운 항목이면 isRead: true 로 조용히 이력에만 추가한다. - * - 토스트 팝업·알림음은 표시하지 않는다. - * STOMP 재연결 시 동일 신호가 재수신되거나 20 초 폴링에서 새로 발견되어도 - * 사용자가 명시적으로 닫은 알림은 다시 열리지 않는다. + * 읽음 여부 3중 확인: + * 1. readIdsRef.current — dismissAllToasts 가 즉시 업데이트하는 인메모리 ref + * 2. loadReadIds() — DB 캐시 기반 값 + * 3. allNotificationsRef — dismissAllToasts 가 functional updater 로 isRead:true + * 를 예약했지만 readIdsRef 에 아직 반영되지 않은 경우를 커버 + * (STOMP/WebSocket 재연결 직후 동일 신호 재수신 시 팝업 방지) */ - // readIdsRef.current (인메모리) 와 DB 캐시(loadReadIds) 모두 확인. - // 앱 시작 초기 설정 로드 전 STOMP 신호 수신 시에도 읽음 상태를 올바르게 판단한다. - const alreadyRead = readIdsRef.current.has(id) || loadReadIds().has(id); + const alreadyRead = + readIdsRef.current.has(id) || + loadReadIds().has(id) || + (allNotificationsRef.current.find(n => n.id === id)?.isRead === true); + + // allNotifications 에는 isRead:true 이지만 readIds 에 빠진 경우 보정 + if (alreadyRead && !readIdsRef.current.has(id)) { + const next = new Set(readIdsRef.current); + next.add(id); + readIdsRef.current = next; + saveReadIds(next); // 디바운스 저장 (즉시 저장 불필요) + } setAllNotifications(prev => { const existing = prev.find(n => n.id === id); if (existing) { // 읽음 처리된 항목은 isRead 를 false 로 되돌리지 않는다 if (existing.isRead || alreadyRead) return prev; - // 미읽음 항목은 receivedAt 만 갱신 return prev.map(n => n.id === id ? { ...n, isRead: false, receivedAt: Date.now() } : n, ); @@ -359,36 +367,45 @@ export const TradeNotificationProvider: React.FC = ({ * 전체 닫기 — 현재 표시된 모든 알림을 읽음 처리하고 즉시 DB 저장. * * 핵심 원칙: - * 1. 사이드 이펙트(readIdsRef 갱신, DB 저장)는 setState 업데이터 밖에서 실행. - * 2. allNotificationsRef 로 현재 목록을 동기적으로 읽어 nextRead 를 구성. - * 3. immediate=true 로 400ms 디바운스 없이 즉시 저장해 이후 폴링과의 + * 1. setAllNotifications 의 functional updater 안에서 nextRead 를 구성한다. + * updater 의 prev 는 같은 배치 내 addNotification 의 setAllNotifications 까지 + * 포함한 최신 상태이므로, dismissAllToasts 직전에 도착한 STOMP 신호도 + * readIds 에 포함된다 (stale ref 문제 해결). + * 2. immediate=true 로 400ms 디바운스 없이 즉시 저장해 이후 폴링과의 * 경쟁 조건(readIds 가 아직 DB 에 없는 상태)을 제거. */ - const nextRead = new Set(readIdsRef.current); - toastNotificationsRef.current.forEach(n => nextRead.add(n.id)); - allNotificationsRef.current.forEach(n => nextRead.add(n.id)); + const baseRead = new Set(readIdsRef.current); + toastNotificationsRef.current.forEach(n => baseRead.add(n.id)); - // 1) 인메모리 ref 즉시 갱신 — addNotification / refreshHistory 가 즉시 참조 - readIdsRef.current = nextRead; + setAllNotifications(prev => { + // prev = 현재 배치의 최신 allNotifications (addNotification 반영 포함) + const nextRead = new Set(baseRead); + prev.forEach(n => nextRead.add(n.id)); - // 2) React state 갱신 (렌더링 예약) - setReadIds(nextRead); - setAllNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); + // 인메모리 ref 즉시 갱신 (addNotification 이 다음 틱에 확인할 때 참조) + readIdsRef.current = nextRead; + + // 캐시 갱신 + 즉시 DB 저장 + saveReadIds(nextRead, true); + + return prev.map(n => ({ ...n, isRead: true })); + }); + + setReadIds(() => readIdsRef.current); setToastNotifications([]); - - // 3) 캐시 갱신 + 즉시 DB 저장 (immediate=true) - saveReadIds(nextRead, true); }, []); const markAllAsRead = useCallback(() => { - const nextRead = new Set(readIdsRef.current); - allNotificationsRef.current.forEach(n => nextRead.add(n.id)); - - readIdsRef.current = nextRead; - setReadIds(nextRead); - setAllNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); + const baseRead = new Set(readIdsRef.current); + setAllNotifications(prev => { + const nextRead = new Set(baseRead); + prev.forEach(n => nextRead.add(n.id)); + readIdsRef.current = nextRead; + saveReadIds(nextRead, true); + return prev.map(n => ({ ...n, isRead: true })); + }); + setReadIds(() => readIdsRef.current); setToastNotifications([]); - saveReadIds(nextRead, true); }, []); const purgeNotifications = useCallback((ids: string[]) => {