From 4f7d9fc1496dc0e8767fa049d3ad77dd2ec1ab9a Mon Sep 17 00:00:00 2001 From: KangShingyu <103213494+KangShinGyu98@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:59:13 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(stomp):=20=EC=9E=AC=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EB=95=8C=20=EB=86=93=EC=B9=9C=20=EC=9D=BC=EC=A0=95=C2=B7?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EB=B3=B5=EA=B5=AC=EC=99=80=20=EC=9E=AC?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=B0=B1=EC=98=A4=ED=94=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20(#247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auth): 세션 확인 API에서 백엔드 장애와 인증 실패 구분 - 백엔드 도달 실패·5xx·429는 503, 인증 실패가 확정된 경우만 401 - refresh 직후 재확인이 장애로 실패해도 새 쿠키 전달 - proxy의 세션 판정은 그대로 유지 Co-Authored-By: Claude Opus 5.5 * fix(stomp): 재연결을 stompjs 지수 백오프에 맡기고 수동 루프 제거 - reconnectDelay·EXPONENTIAL·maxReconnectDelay(5초)로 재시도 간격 위임 - 재연결 직전 세션 확인을 beforeConnect로 옮기고 401이면 재연결 중단 후 로그아웃 - 연결 문제 배너는 끊긴 지 45초가 지나면 표시하고 연결되면 해제 - 다시 시도는 연결 시도 중이 아닐 때만 바로 재연결 Co-Authored-By: Claude Opus 5.5 * fix(stomp): 네트워크가 돌아오면 재연결 예약을 기다리지 않고 바로 연결 - 브라우저 online 이벤트에서 다시 시도와 같은 경로로 즉시 재연결 Co-Authored-By: Claude Opus 5.5 * fix(stomp): 끊긴 동안 권한을 잃은 방은 재연결 뒤 퇴장 - 구독 오류(ERROR)를 받으면 방 접근을 HTTP로 확인해 거부면 퇴장 처리 - 구독 오류로 연결이 닫히면 5초 뒤 다시 연결해 1초 간격 반복 방지 Co-Authored-By: Claude Opus 5.5 * fix(rooms): 화면 밖에서 무효화된 일정·멤버·안읽음 배지를 다시 볼 때 재조회 - refetchOnMount: false 제거, staleTime: Infinity는 유지해 무효화된 경우에만 조회 Co-Authored-By: Claude Opus 5.5 * fix(plan): 일정 전체 재조회를 한 조회로 모아 늦은 응답이 새 일정을 덮지 않게 - 취소된 일정 조회는 캐시에 쓰지 않고, 미리보기를 기다린 뒤 한 번에 쓰기 - 본인 일차 변경·여행 날짜 변경·ROOM_SCHEDULES_RESYNCED는 room-schedules 조회를 거쳐 재조회 - 일차 목록을 고치는 동안 전체 재조회가 진행 중이면 다시 시작 - 재조회로 순서가 바뀐 일차는 이웃이 바뀐 구간 경로만 무효화 Co-Authored-By: Claude Opus 5.5 * fix(stomp): 방 토픽을 구독할 때마다 놓친 방 데이터 다시 받기 - 첫 구독·재연결·/home 복귀·방 전환 때 일정·북마크·안읽음 배지를 무효화 - 개인 큐를 구독할 때 놓친 입장 요청 목록을 무효화 - 화면에 있는 조회만 바로 다시 받고, 준비물·가계부·채팅 목록은 대상에서 제외 Co-Authored-By: Claude Opus 5.5 * fix(ui): 재조회가 실패해도 보이던 일정·북마크 목록 유지 - 모바일 일정·경로 카드·북마크 폴더·폴더 상세·북마크에서 추가 모달은 처음 불러오기에 실패했을 때만 오류 화면 - 경로 카드는 재조회 실패 뒤에도 받아 둔 일정으로 장소 목록을 계속 읽음 Co-Authored-By: Claude Opus 5.5 * fix(stomp): 멤버·접속 상태 패치가 다시 받을 멤버 목록의 재조회를 지우지 않게 - 무효화된 멤버 목록에는 이벤트를 패치하지 않아 다음 마운트 때 재조회 유지 - 멤버 목록을 받은 적이 없으면 한 명짜리 목록을 만들지 않음 Co-Authored-By: Claude Opus 5.5 * fix(plan): 드래그 중 일정이 바뀌면 순서를 저장하지 않고 알림 - 데스크톱 같은 일차·다른 일차, 모바일, 일차 이동 모두 드래그 시작 때 순서와 놓을 때 순서를 비교 - 같은 일차 이동은 드롭 시점 목록 대신 드래그한 항목 id로 요청 Co-Authored-By: Claude Opus 5.5 * fix(plan): 연결이 끊긴 동안과 놓친 변경을 다시 받는 동안 일정 드래그 잠금 - 데스크톱·모바일·일차 이동 손잡이는 기존 비활성 표시를 쓰고 목록은 그대로 보임 - 드래그 중에 끊기면 놓아도 저장하지 않고 알림 Co-Authored-By: Claude Opus 5.5 * fix(chat): 연결이 바뀌어도 읽음 처리 함수를 그대로 유지 - STOMP client·연결 상태를 보낼 때 ref로 읽어 markMessagesRead가 연결마다 새로 만들어지지 않게 함 - 이 함수에 기대는 채팅 최초 기록 조회가 재연결 때마다 다시 시작되던 원인을 없앰 Co-Authored-By: Claude Opus 5.5 * fix(chat): 실시간 채팅을 sequence 커서로 붙이고 놓친 메시지를 끝까지 따라잡기 - 기록 조회·최신으로 이동·아래 페이지·따라잡기 중 받은 메시지를 모았다가 결과 반영 뒤 같은 규칙으로 처리(로딩 중 받은 일반 채팅 유실 수정) - 커서+1만 목록 끝에 붙이고, 번호가 비면 afterSequence로 200개씩 끝까지 받음 - 재연결·다른 방에서 돌아올 때 놓친 메시지를 따라잡고, 재연결로 새로 붙은 메시지 앞에 구분선을 두고 그 자리로 스크롤 - 중간 창에서는 새 메시지를 붙이지 않고 같은 id만 갱신, AI 요청은 대기 상태로 되돌리지 않음 - 빈 방은 afterSequence=0 대신 최신 페이지를 받고, 400·403·404는 다시 보내지 않음(네트워크·5xx는 1·2·4초 간격 재시도) - 최초 기록 조회가 재연결로 멈추지 않게 하고, 끝내 실패했으면 재연결 때 다시 받음 - 목록을 sequence 순으로 정렬하고, 최신으로 이동은 목록을 비우지 않고 한 번에 교체 - 메시지·읽음 위치 조회 API가 HttpError를 던지게 함 Co-Authored-By: Claude Opus 5.5 * fix(chat): 거의 동시에 보낸 채팅이 보낸 순서대로 보이게 정렬 - 서버는 저장 충돌을 재시도한 순서로 sequence를 매겨, 번호 순 정렬이면 1~4가 3·4·1·2로 보였음 - 목록은 작성 시각 순으로 되돌리고, sequence 순서는 빈 번호 판단에만 사용 - 최초 기록·최신으로 이동 결과도 작성 시각 순으로 정렬 Co-Authored-By: Claude Opus 5.5 * test(chat): 놓친 채팅 페이지 테스트의 기대 개수 수정 Co-Authored-By: Claude Opus 5.5 * fix(plan): 드래그 중 일정이 바뀌어도 놓은 자리로 순서 저장 - 드래그 시작과 놓을 때의 순서 비교, "일정이 바뀌어서 옮기지 않았어요" 알림 제거(데스크톱·모바일·일차 이동) - 같은 일차 이동은 계속 드래그한 항목 id로 요청 - 연결이 끊긴 동안과 다시 받는 동안의 드래그 잠금은 유지 Co-Authored-By: Claude Opus 5.5 * fix(stomp): 재연결을 7회 실패하면 멈추고 배너 표시 - 재연결 간격 상한을 5초에서 30초로 늘림(1~2초 시작, 2배씩) - 끊긴 뒤 7번째 재연결까지 실패하면 재연결을 멈추고 연결 문제 배너를 띄움 - 45초 타이머 배너를 없앰 - 배너의 '다시 시도'는 횟수를 처음부터 다시 셈. 멈춘 뒤 online 이벤트로는 다시 시작하지 않음 Co-Authored-By: Claude Opus 5.5 * fix(stomp): 재연결을 멈춘 뒤에도 네트워크가 돌아오면 다시 연결 - 7회 실패로 재연결을 멈춘 뒤 online 이벤트가 오면 횟수를 처음부터 다시 세며 재연결 - 다시 온라인이 됐다는 것은 인터넷이 완전히 끊겼다가 돌아온 경우를 말하고, 클릭이나 화면 전환은 포함하지 않음 - 화면 전환·클릭에 재시도를 걸면 무한히 재시도하게 되고, 이 상황은 다른 호출은 멀쩡하고 ws만 막힌 한정적인 경우라서 '다시 시도' 버튼에 맡김 Co-Authored-By: Claude Opus 5.5 * style(stomp): 연결 문제 배너 배경을 불투명하게 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../_components/BookmarkFolderDetailView.tsx | 3 +- .../_components/BookmarkFoldersView.tsx | 6 +- .../map/_components/useMobileRouteView.tsx | 5 +- .../itinerary/AddFromBookmarkModal.tsx | 4 +- .../_components/itinerary/PlanPageView.tsx | 4 +- .../_components/mobile/mobilePlanDrag.test.ts | 1 + .../_components/mobile/mobilePlanDrag.tsx | 18 +- .../stomp/StompConnectionBanner.tsx | 2 +- src/contexts/stomp/StompProvider.tsx | 47 +- .../stomp/use-stomp-provider-effects.test.tsx | 310 ++++++++- .../stomp/use-stomp-provider-effects.tsx | 135 +++- src/hooks/useChatMessageRead.test.tsx | 23 + src/hooks/useChatMessageRead.ts | 13 +- src/hooks/useChatMessages.recovery.test.tsx | 317 +++++++++ src/hooks/useChatMessages.ts | 643 +++++++++++------- src/hooks/useExpenseInvalidation.test.ts | 6 +- ...usePlanItineraryReorder.analytics.test.tsx | 89 +++ src/hooks/usePlanItineraryReorder.ts | 26 +- src/hooks/usePlanScheduleDayReorder.ts | 19 +- src/hooks/useRoomSchedules.test.tsx | 47 ++ src/hooks/useRoomUnreadCount.ts | 1 - src/hooks/useRooms.analytics.test.tsx | 8 +- src/hooks/useRooms.ts | 21 +- src/hooks/useScheduleResyncPending.ts | 16 + src/lib/api/rooms/messages.ts | 2 + src/lib/api/rooms/schedules.ts | 3 +- src/lib/auth-server.test.ts | 171 +++++ src/lib/auth-server.ts | 68 +- src/lib/chat.test.ts | 140 ++++ src/lib/chat.ts | 132 +++- src/lib/chat/messageCatchUp.test.ts | 120 ++++ src/lib/chat/messageCatchUp.ts | 87 +++ src/lib/plan/planItemReorder.ts | 4 + src/lib/plan/schedule-bulk-hydration.ts | 65 +- src/lib/plan/scheduleItemPlaces.test.ts | 43 ++ src/lib/plan/scheduleItemPlaces.ts | 29 +- src/lib/plan/scheduleStompRouteScope.test.ts | 30 + src/lib/plan/scheduleStompRouteScope.ts | 30 + src/lib/query-keys.ts | 5 + src/lib/rooms.hydrate.test.ts | 98 +++ src/lib/rooms.ts | 52 +- src/lib/stomp/client.test.ts | 18 + src/lib/stomp/client.ts | 14 +- src/lib/stomp/forced-room-exit-dispatch.ts | 8 + src/lib/stomp/members-dispatch.test.ts | 76 +++ src/lib/stomp/members-dispatch.tsx | 58 +- src/lib/stomp/presence-dispatch.tsx | 5 + src/lib/stomp/room-resync-dispatch.test.ts | 77 +++ src/lib/stomp/room-resync-dispatch.ts | 40 ++ src/lib/stomp/schedules-dispatch.test.ts | 1 - src/lib/stomp/schedules-dispatch.ts | 9 +- src/lib/stomp/stomp-session-recovery.test.ts | 104 +++ src/lib/stomp/stomp-session-recovery.ts | 107 +-- src/lib/stomp/subscribe-room-topics.test.ts | 17 +- src/lib/stomp/subscribe-room-topics.ts | 2 + src/lib/stomp/subscribe-user-rooms-queue.ts | 2 + src/stores/stomp-connection-store.ts | 16 +- 57 files changed, 2848 insertions(+), 549 deletions(-) create mode 100644 src/hooks/useChatMessages.recovery.test.tsx create mode 100644 src/hooks/useRoomSchedules.test.tsx create mode 100644 src/hooks/useScheduleResyncPending.ts create mode 100644 src/lib/auth-server.test.ts create mode 100644 src/lib/chat.test.ts create mode 100644 src/lib/chat/messageCatchUp.test.ts create mode 100644 src/lib/chat/messageCatchUp.ts create mode 100644 src/lib/plan/scheduleStompRouteScope.test.ts create mode 100644 src/lib/rooms.hydrate.test.ts create mode 100644 src/lib/stomp/client.test.ts create mode 100644 src/lib/stomp/members-dispatch.test.ts create mode 100644 src/lib/stomp/room-resync-dispatch.test.ts create mode 100644 src/lib/stomp/room-resync-dispatch.ts create mode 100644 src/lib/stomp/stomp-session-recovery.test.ts diff --git a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx index 1bc73da3..60fa1614 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx @@ -36,7 +36,8 @@ export function BookmarkFolderDetailView({ folder }: { folder: BookmarkFolder }) const { data: bookmarkRowsRaw, isPending: bookmarksLoading, - isError: bookmarksError, + // 재조회 실패는 보이던 목록을 지우지 않는다 — 처음 불러오기에 실패했을 때만 오류 화면 + isLoadingError: bookmarksError, error: bookmarksErr, refetch, } = useRoomBookmarks(roomId, categoryIdOk); diff --git a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx index a6c146a7..4d7e5a29 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx @@ -38,7 +38,8 @@ export function BookmarkFoldersView() { const roomId = useSessionStore((s) => s.currentRoomId); const { folders } = useBookmarkFolders(); useAllRoomBookmarks(roomId); - const { isPending, isError, error, refetch } = useBookmarkCategories(roomId); + const { isPending, isLoadingError, error, refetch } = + useBookmarkCategories(roomId); const { mutate: createCategory, isPending: isCreating } = useCreateBookmarkCategory(); const { @@ -222,7 +223,8 @@ export function BookmarkFoldersView() { ); } - if (isError) { + // 재조회 실패는 보이던 폴더를 지우지 않는다 — 처음 불러오기에 실패했을 때만 오류 화면 + if (isLoadingError) { return (
diff --git a/src/app/(main)/map/_components/useMobileRouteView.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx index eadc8e74..b9c4a39a 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.tsx @@ -123,7 +123,8 @@ export function useMobileRouteView({ queries: schedules.map((s) => ({ queryKey: scheduleItemsQueryKey(rid || null, s.scheduleId), queryFn: () => readSchedulePlanPlacesFromCache(queryClient, rid, s.scheduleId), - enabled: active && rid.length > 0 && schedulesQuery.isSuccess, + // 재조회가 실패해도(status 'error') 받아 둔 일정으로 계속 그린다 + enabled: active && rid.length > 0 && schedulesQuery.data !== undefined, staleTime: Infinity, refetchOnMount: false, refetchOnWindowFocus: false, @@ -425,7 +426,7 @@ export function useMobileRouteView({ return null; } else if (schedulesQuery.isLoading || (schedules.length > 0 && entries.length === 0)) { return ; - } else if (schedulesQuery.isError) { + } else if (schedulesQuery.isLoadingError) { return ( diff --git a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx index 74a0da5e..bfef1d87 100644 --- a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx +++ b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx @@ -54,7 +54,7 @@ export function AddFromBookmarkModal({ const { data: categories, isPending: categoriesLoading, - isError: categoriesError, + isLoadingError: categoriesError, error: categoriesErr, refetch: refetchCategories, } = useBookmarkCategories(roomId); @@ -82,7 +82,7 @@ export function AddFromBookmarkModal({ const { data: bookmarkRows, isPending: bookmarksLoading, - isError: bookmarksError, + isLoadingError: bookmarksError, error: bookmarksErr, refetch: refetchBookmarks, } = useRoomBookmarks(roomId, selectedCategoryId); diff --git a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx index 91ec25a4..bee1cee9 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx @@ -71,6 +71,7 @@ export function PlanPageView() { data: schedules, isPending, isError, + isLoadingError, } = useRoomSchedules(roomIdForQueries); const { mutate: deleteSchedule, isPending: isDeletingSchedule } = useDeleteRoomSchedule(); @@ -200,7 +201,8 @@ export function PlanPageView() { roomId={roomId} schedules={sortedSchedules} isLoading={showInitialLoading} - isError={isError} + // 재조회 실패는 보이던 일정을 지우지 않는다 — 처음 불러오기에 실패했을 때만 오류로 바꾼다 + isError={isLoadingError} menuDisabled={isCreatingSchedule || isDeletingSchedule || isMovePending} onRequestDeleteDay={handleDeleteScheduleDay} /> diff --git a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts index 2335a017..31bcd521 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts @@ -7,6 +7,7 @@ vi.mock("@/hooks/useRooms", () => ({ useReorderScheduleItem: vi.fn(), })); vi.mock("@/lib/analytics/track", () => ({ AnalyticsEvents: {}, trackAnalyticsEvent: vi.fn() })); +vi.mock("@/hooks/useScheduleResyncPending", () => ({ useScheduleResyncPending: () => false })); const { applyPendingPlanMove, mobilePlanRowOffset } = await import("./mobilePlanDrag"); diff --git a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx index 5b34c5d6..5d0b3462 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx @@ -17,9 +17,13 @@ import { useMoveScheduleItemToSchedule, useReorderScheduleItem, } from "@/hooks/useRooms"; +import { useScheduleResyncPending } from "@/hooks/useScheduleResyncPending"; import { bucketItemCount } from "@/lib/analytics/context"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; -import { clampCrossDayTargetOrderIndex } from "@/lib/plan/planItemReorder"; +import { + clampCrossDayTargetOrderIndex, + PLAN_ORDER_RESYNCING_MESSAGE, +} from "@/lib/plan/planItemReorder"; import { newOrderIndexAfterMove } from "@/lib/plan/scheduleItemPlaces"; import type { PlanPlace } from "@/lib/plan/types"; @@ -163,7 +167,9 @@ export function useMobilePlanDragController({ const { mutateAsync: reorderMutate, isReorderSettling } = useReorderScheduleItem(); const { mutateAsync: moveMutate, isPending: isMovePending } = useMoveScheduleItemToSchedule(); - const dragDisabled = isReorderSettling || isMovePending || pending !== null; + const scheduleResyncPending = useScheduleResyncPending(roomId); + const dragDisabled = + isReorderSettling || isMovePending || pending !== null || scheduleResyncPending; const updateActive = useCallback((next: ActiveDrag | null) => { activeRef.current = next; @@ -236,6 +242,12 @@ export function useMobilePlanDragController({ if (typeof itemId !== "number") return; const sourcePlaces = placesBySchedule.current.get(sourceScheduleId) ?? []; + // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 + if (scheduleResyncPending) { + toast.error(PLAN_ORDER_RESYNCING_MESSAGE); + return; + } + if (targetScheduleId === sourceScheduleId) { if (targetIndex === sourceIndex) return; const order = sourcePlaces.map((p) => p.id); @@ -280,7 +292,7 @@ export function useMobilePlanDragController({ } finally { setPending(null); } - }, [moveMutate, reorderMutate, roomId, updateActive]); + }, [moveMutate, reorderMutate, roomId, scheduleResyncPending, updateActive]); const startDrag = useCallback( (scheduleId: number, index: number, e: ReactPointerEvent) => { diff --git a/src/components/stomp/StompConnectionBanner.tsx b/src/components/stomp/StompConnectionBanner.tsx index 51bde782..89f7bb8a 100644 --- a/src/components/stomp/StompConnectionBanner.tsx +++ b/src/components/stomp/StompConnectionBanner.tsx @@ -26,7 +26,7 @@ export function StompConnectionBanner({ onRetry }: StompConnectionBannerProps) { animate={{ height: "auto", opacity: 1 }} exit={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }} transition={reduceMotion ? { duration: 0 } : { duration: 0.2 }} - className="fixed top-0 right-0 left-0 z-50 overflow-hidden border-b border-primary/25 bg-primary/[0.06] shadow-sm" + className="fixed top-0 right-0 left-0 z-50 overflow-hidden border-b border-primary/25 bg-primary-subtle shadow-sm" >
diff --git a/src/contexts/stomp/StompProvider.tsx b/src/contexts/stomp/StompProvider.tsx index b4bcc050..81d8cdae 100644 --- a/src/contexts/stomp/StompProvider.tsx +++ b/src/contexts/stomp/StompProvider.tsx @@ -17,6 +17,7 @@ import { evictRoomFromClientCaches, refreshRoomsList, showForcedExitToast, + showRoomAccessLostToast, showRoomLeftToast, } from "@/lib/stomp/forced-room-exit-dispatch"; import { @@ -34,11 +35,11 @@ import { import type { ServerChatMessage } from "@/types/chat"; import { StompConnectionBanner } from "@/components/stomp/StompConnectionBanner"; -import { handleStompReconnect } from "@/lib/stomp/stomp-session-recovery"; import { useStompConnectionStore } from "@/stores/stomp-connection-store"; import { useStompClientLifecycleEffect, + useStompOnlineRetryEffect, useStompRoomTopicsResyncEffect, type StompConnectionState, } from "./use-stomp-provider-effects"; @@ -91,6 +92,7 @@ export function StompProvider({ children }: { children: ReactNode }) { const lastSubscribedRoomIdRef = useRef(null); const forcedExitConsumedRef = useRef(false); const suppressCloseRecoveryRef = useRef(false); + const retryConnectionRef = useRef<(() => void) | null>(null); const [connectionState, setConnectionState] = useState({ client: null, connected: false, @@ -102,19 +104,8 @@ export function StompProvider({ children }: { children: ReactNode }) { }, []); const retryStompConnection = useCallback(() => { - const client = clientRef.current; - if (!client) return; - - useStompConnectionStore.getState().resumeAutoRecovery(); - setConnectionState((prev) => ({ ...prev, connected: false })); - invalidateRoomTopics(); - void handleStompReconnect({ - client, - queryClient: queryClientRef.current, - isSuppressed: () => suppressCloseRecoveryRef.current, - isActive: () => clientRef.current === client, - }); - }, [invalidateRoomTopics]); + retryConnectionRef.current?.(); + }, []); const detachRoomTopicsOnly = useCallback(() => { roomTopicsUnsubRef.current?.(); @@ -184,6 +175,30 @@ export function StompProvider({ children }: { children: ReactNode }) { [getResolvedRoomId, handleForcedRoomExit], ); + /** 끊긴 동안 접근을 잃은 방에 다시 구독하다 거부됐을 때 — 놓친 개인 큐 알림 대신 HTTP 확인 결과로 내보낸다 */ + const notifyRoomAccessLost = useCallback( + (roomId: string) => { + const rid = roomId.trim(); + if (!rid) return; + + evictRoomFromClientCaches(queryClientRef.current, rid); + refreshRoomsList(queryClientRef.current); + showRoomAccessLostToast(rid); + + if ((getResolvedRoomId()?.trim() ?? "") === rid) { + if (forcedExitConsumedRef.current) return; + forcedExitConsumedRef.current = true; + handleForcedRoomExit(); + return; + } + + if (useSessionStore.getState().currentRoomId?.trim() === rid) { + useSessionStore.getState().clearCurrentRoomId(); + } + }, + [getResolvedRoomId, handleForcedRoomExit], + ); + const roomChatMessageHandlerRef = useRef< ((msg: ServerChatMessage) => void) | null >(null); @@ -237,6 +252,7 @@ export function StompProvider({ children }: { children: ReactNode }) { stompEligible, suppressCloseRecoveryRef, notifyForcedRoomExit, + notifyRoomAccessLost, subscribeToRoomTopics, invalidateRoomTopics, teardownConnectedClient, @@ -247,8 +263,11 @@ export function StompProvider({ children }: { children: ReactNode }) { forcedExitConsumedRef, queryClientRef, setConnectionState, + retryConnectionRef, }); + useStompOnlineRetryEffect(retryConnectionRef); + const roomTopicsDeferred = pathDefersRoomStompRoomTopics(pathname); useStompRoomTopicsResyncEffect({ diff --git a/src/contexts/stomp/use-stomp-provider-effects.test.tsx b/src/contexts/stomp/use-stomp-provider-effects.test.tsx index 9a3fde0e..84fe7b70 100644 --- a/src/contexts/stomp/use-stomp-provider-effects.test.tsx +++ b/src/contexts/stomp/use-stomp-provider-effects.test.tsx @@ -2,10 +2,15 @@ import { act } from "react"; import { QueryClient } from "@tanstack/react-query"; import type { Client } from "@stomp/stompjs"; -import { expect, it, vi } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import { hookHarness } from "@/test/hook-harness"; +import { + STOMP_CONNECTION_ISSUE_MESSAGE, + useStompConnectionStore, +} from "@/stores/stomp-connection-store"; import { useStompClientLifecycleEffect, + useStompOnlineRetryEffect, useStompRoomTopicsResyncEffect, } from "./use-stomp-provider-effects"; const mocks = vi.hoisted(() => ({ @@ -13,10 +18,17 @@ const mocks = vi.hoisted(() => ({ ping: vi.fn(), queue: vi.fn(), stop: vi.fn(), + refresh: vi.fn(), + validate: vi.fn(), })); vi.mock("@/lib/stomp/client", () => ({ createStompClient: mocks.create, getStompBrokerURL: () => "http://fixture.invalid/ws", + STOMP_MAX_RECONNECT_ATTEMPTS: 7, + STOMP_MAX_RECONNECT_DELAY_MS: 30_000, +})); +vi.mock("@/lib/rooms", () => ({ + validateRoomAccess: mocks.validate, })); vi.mock("@/lib/stomp/sessionPresencePing", () => ({ startSessionPresencePing: mocks.ping, @@ -25,30 +37,57 @@ vi.mock("@/lib/stomp/subscribe-user-rooms-queue", () => ({ subscribeUserRoomsQueue: mocks.queue, })); vi.mock("@/lib/stomp/stomp-session-recovery", () => ({ - handleStompReconnect: vi.fn(), + refreshSessionBeforeReconnect: mocks.refresh, STOMP_ACCESS_TOKEN_EXPIRED_CLOSE_CODE: 4001, })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -it("stable dependencies keep one client and pathname/room resync uses current committed refs", async () => { - const client = { + +const CONNECTED_FRAME = { + command: "CONNECTED", + headers: {}, + body: "", + isBinaryBody: false, + binaryBody: new Uint8Array(), +}; +const ABNORMAL_CLOSE = { code: 1006 }; +const PROTOCOL_ERROR_CLOSE = { code: 1002 }; +const RECONNECT_CAP_MS = 30_000; +const MAX_RECONNECT_ATTEMPTS = 7; + +type FakeClient = { + activate: ReturnType; + deactivate: ReturnType; + connected: boolean; + beforeConnect: () => Promise; + onConnect: (frame: typeof CONNECTED_FRAME) => void; + onStompError: () => void; + onWebSocketClose: (event: { code: number }) => void; +}; + +function fakeClient(connected = false): FakeClient { + return { activate: vi.fn(), - connected: true, + deactivate: vi.fn(() => Promise.resolve()), + connected, + beforeConnect: () => Promise.resolve(), onConnect: () => {}, + onStompError: () => {}, onWebSocketClose: () => {}, - } as unknown as Client; + }; +} + +function lifecycleOpts(client: FakeClient) { mocks.create.mockReturnValue(client); mocks.ping.mockReturnValue(mocks.stop); mocks.queue.mockReturnValue(vi.fn()); - const subscribe = vi.fn(); - const teardown = vi.fn(); - const detach = vi.fn(); - const opts = { + return { stompEligible: true, suppressCloseRecoveryRef: { current: false }, notifyForcedRoomExit: vi.fn(), - subscribeToRoomTopics: subscribe, + notifyRoomAccessLost: vi.fn(), + subscribeToRoomTopics: vi.fn(), invalidateRoomTopics: vi.fn(), - teardownConnectedClient: teardown, + teardownConnectedClient: vi.fn(), clientRef: { current: null as Client | null }, userRoomsQueueUnsubRef: { current: null as (() => void) | null }, getResolvedRoomId: () => "a", @@ -56,7 +95,21 @@ it("stable dependencies keep one client and pathname/room resync uses current co forcedExitConsumedRef: { current: false }, queryClientRef: { current: new QueryClient() }, setConnectionState: vi.fn(), + retryConnectionRef: { current: null as (() => void) | null }, }; +} + +afterEach(() => { + vi.clearAllMocks(); + useStompConnectionStore.setState({ connectionIssue: null }); +}); + +it("stable dependencies keep one client and pathname/room resync uses current committed refs", async () => { + const client = fakeClient(true); + const opts = lifecycleOpts(client); + const subscribe = opts.subscribeToRoomTopics; + const teardown = opts.teardownConnectedClient; + const detach = vi.fn(); const hook = hookHarness((p: { room: string; deferred: boolean }) => { useStompClientLifecycleEffect(opts); useStompRoomTopicsResyncEffect({ @@ -70,15 +123,7 @@ it("stable dependencies keep one client and pathname/room resync uses current co }); }); await hook.render({ room: "a", deferred: false }); - await act(async () => - client.onConnect({ - command: "CONNECTED", - headers: {}, - body: "", - isBinaryBody: false, - binaryBody: new Uint8Array(), - }), - ); + await act(async () => client.onConnect(CONNECTED_FRAME)); await hook.render({ room: "b", deferred: false }); expect(subscribe.mock.lastCall?.[1]).toBe("b"); await hook.render({ room: "b", deferred: true }); @@ -90,3 +135,226 @@ it("stable dependencies keep one client and pathname/room resync uses current co expect(mocks.stop).toHaveBeenCalled(); opts.queryClientRef.current.clear(); }); + +it("leaves reconnecting to stompjs when the socket closes", async () => { + const client = fakeClient(); + const opts = lifecycleOpts(client); + const hook = hookHarness(() => useStompClientLifecycleEffect(opts)); + await hook.render(undefined); + + client.onWebSocketClose(ABNORMAL_CLOSE); + + expect(client.activate).toHaveBeenCalledTimes(1); + expect(client.deactivate).not.toHaveBeenCalled(); + expect(opts.invalidateRoomTopics).toHaveBeenCalledTimes(1); + await hook.unmount(); +}); + +it("refreshes the session only before attempts that follow a dropped connection", async () => { + const client = fakeClient(); + const opts = lifecycleOpts(client); + const hook = hookHarness(() => useStompClientLifecycleEffect(opts)); + await hook.render(undefined); + + await client.beforeConnect(); + expect(mocks.refresh).not.toHaveBeenCalled(); + + client.onWebSocketClose(ABNORMAL_CLOSE); + await client.beforeConnect(); + expect(mocks.refresh).toHaveBeenCalledTimes(1); + expect(mocks.refresh.mock.lastCall?.[0].isActive()).toBe(true); + + await hook.unmount(); + expect(mocks.refresh.mock.lastCall?.[0].isActive()).toBe(false); +}); + +/** 끊긴 뒤 재연결 시도와 실패를 count번 겪게 한다 */ +async function failReconnects(client: FakeClient, count: number) { + for (let i = 0; i < count; i += 1) { + await client.beforeConnect(); + client.onWebSocketClose(ABNORMAL_CLOSE); + } +} + +it("stops reconnecting and shows the banner once the last allowed attempt fails", async () => { + const client = fakeClient(); + const opts = lifecycleOpts(client); + const hook = hookHarness(() => useStompClientLifecycleEffect(opts)); + await hook.render(undefined); + + client.onWebSocketClose(ABNORMAL_CLOSE); + await failReconnects(client, MAX_RECONNECT_ATTEMPTS - 1); + expect(client.deactivate).not.toHaveBeenCalled(); + expect(useStompConnectionStore.getState().connectionIssue).toBeNull(); + + await failReconnects(client, 1); + expect(client.deactivate).toHaveBeenCalledTimes(1); + expect(useStompConnectionStore.getState().connectionIssue).toBe( + STOMP_CONNECTION_ISSUE_MESSAGE, + ); + + client.onConnect(CONNECTED_FRAME); + expect(useStompConnectionStore.getState().connectionIssue).toBeNull(); + await hook.unmount(); +}); + +it("counts attempts again from zero after the connection recovers", async () => { + const client = fakeClient(); + const opts = lifecycleOpts(client); + const hook = hookHarness(() => useStompClientLifecycleEffect(opts)); + await hook.render(undefined); + + client.onWebSocketClose(ABNORMAL_CLOSE); + await failReconnects(client, MAX_RECONNECT_ATTEMPTS - 1); + await client.beforeConnect(); + client.onConnect(CONNECTED_FRAME); + + client.onWebSocketClose(ABNORMAL_CLOSE); + await failReconnects(client, MAX_RECONNECT_ATTEMPTS - 1); + + expect(client.deactivate).not.toHaveBeenCalled(); + expect(useStompConnectionStore.getState().connectionIssue).toBeNull(); + await hook.unmount(); +}); + +it("gives the retry button a fresh round of attempts after reconnecting stopped", async () => { + const client = fakeClient(); + const opts = lifecycleOpts(client); + const hook = hookHarness(() => useStompClientLifecycleEffect(opts)); + await hook.render(undefined); + + client.onWebSocketClose(ABNORMAL_CLOSE); + await failReconnects(client, MAX_RECONNECT_ATTEMPTS); + expect(client.deactivate).toHaveBeenCalledTimes(1); + + opts.retryConnectionRef.current?.(); + await hook.flush(); + expect(client.deactivate).toHaveBeenCalledTimes(2); + expect(client.activate).toHaveBeenCalledTimes(2); + + await failReconnects(client, MAX_RECONNECT_ATTEMPTS - 1); + expect(client.deactivate).toHaveBeenCalledTimes(2); + await hook.unmount(); +}); + +it("retries immediately unless a connection attempt is in flight", async () => { + const client = fakeClient(); + const opts = lifecycleOpts(client); + const hook = hookHarness(() => useStompClientLifecycleEffect(opts)); + await hook.render(undefined); + + await client.beforeConnect(); + opts.retryConnectionRef.current?.(); + expect(client.deactivate).not.toHaveBeenCalled(); + + client.onWebSocketClose(ABNORMAL_CLOSE); + opts.retryConnectionRef.current?.(); + await hook.flush(); + expect(client.deactivate).toHaveBeenCalledTimes(1); + expect(client.activate).toHaveBeenCalledTimes(2); + + await hook.unmount(); + expect(opts.retryConnectionRef.current).toBeNull(); +}); + +it("reconnects right away when the browser reports the network is back", async () => { + const retry = vi.fn(); + const retryConnectionRef = { current: retry as (() => void) | null }; + const hook = hookHarness(() => useStompOnlineRetryEffect(retryConnectionRef)); + await hook.render(undefined); + + window.dispatchEvent(new Event("online")); + expect(retry).toHaveBeenCalledTimes(1); + + await hook.unmount(); + window.dispatchEvent(new Event("online")); + expect(retry).toHaveBeenCalledTimes(1); +}); + +it("restarts reconnecting on online even after it stopped and the banner is shown", async () => { + const retry = vi.fn(); + const retryConnectionRef = { current: retry as (() => void) | null }; + const hook = hookHarness(() => useStompOnlineRetryEffect(retryConnectionRef)); + await hook.render(undefined); + useStompConnectionStore.setState({ connectionIssue: STOMP_CONNECTION_ISSUE_MESSAGE }); + + window.dispatchEvent(new Event("online")); + + expect(retry).toHaveBeenCalledTimes(1); + await hook.unmount(); +}); + +it("exits the room when a subscription error turns out to be lost access", async () => { + mocks.validate.mockResolvedValue("forbidden"); + const client = fakeClient(); + const opts = lifecycleOpts(client); + const hook = hookHarness(() => useStompClientLifecycleEffect(opts)); + await hook.render(undefined); + + client.onStompError(); + client.onStompError(); + await hook.flush(); + + expect(mocks.validate).toHaveBeenCalledTimes(1); + expect(mocks.validate).toHaveBeenCalledWith("a"); + expect(opts.notifyRoomAccessLost).toHaveBeenCalledWith("a"); + await hook.unmount(); +}); + +it("keeps the room when access is still valid after a subscription error", async () => { + mocks.validate.mockResolvedValue("ok"); + const client = fakeClient(); + const opts = lifecycleOpts(client); + const hook = hookHarness(() => useStompClientLifecycleEffect(opts)); + await hook.render(undefined); + + client.onStompError(); + await hook.flush(); + + expect(opts.notifyRoomAccessLost).not.toHaveBeenCalled(); + await hook.unmount(); +}); + +it("waits the capped delay before reconnecting after the server closes on a subscription error", async () => { + vi.useFakeTimers(); + try { + mocks.validate.mockResolvedValue("ok"); + const client = fakeClient(); + const opts = lifecycleOpts(client); + const hook = hookHarness(() => useStompClientLifecycleEffect(opts)); + await hook.render(undefined); + + client.onStompError(); + client.onWebSocketClose(PROTOCOL_ERROR_CLOSE); + // 라이브러리 재연결을 멈춰야 CONNECTED 때 초기화되는 1초 간격으로 반복하지 않는다 + expect(client.deactivate).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(RECONNECT_CAP_MS - 1); + expect(client.activate).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(client.activate).toHaveBeenCalledTimes(2); + await hook.unmount(); + } finally { + vi.useRealTimers(); + } +}); + +it("leaves an ordinary close to the stompjs reconnect", async () => { + vi.useFakeTimers(); + try { + const client = fakeClient(); + const opts = lifecycleOpts(client); + const hook = hookHarness(() => useStompClientLifecycleEffect(opts)); + await hook.render(undefined); + + client.onWebSocketClose(ABNORMAL_CLOSE); + await vi.advanceTimersByTimeAsync(RECONNECT_CAP_MS); + + expect(client.deactivate).not.toHaveBeenCalled(); + expect(client.activate).toHaveBeenCalledTimes(1); + expect(mocks.validate).not.toHaveBeenCalled(); + await hook.unmount(); + } finally { + vi.useRealTimers(); + } +}); diff --git a/src/contexts/stomp/use-stomp-provider-effects.tsx b/src/contexts/stomp/use-stomp-provider-effects.tsx index 7626adbc..79c675a3 100644 --- a/src/contexts/stomp/use-stomp-provider-effects.tsx +++ b/src/contexts/stomp/use-stomp-provider-effects.tsx @@ -6,16 +6,25 @@ import { useEffect } from "react"; import type { QueryClient } from "@tanstack/react-query"; import type { Client } from "@stomp/stompjs"; -import { createStompClient, getStompBrokerURL } from "@/lib/stomp/client"; +import { validateRoomAccess } from "@/lib/rooms"; +import { + createStompClient, + getStompBrokerURL, + STOMP_MAX_RECONNECT_ATTEMPTS, + STOMP_MAX_RECONNECT_DELAY_MS, +} from "@/lib/stomp/client"; import { pathDefersRoomStompRoomTopics } from "@/lib/stomp/stompPathPolicy"; import { - handleStompReconnect, + refreshSessionBeforeReconnect, STOMP_ACCESS_TOKEN_EXPIRED_CLOSE_CODE, } from "@/lib/stomp/stomp-session-recovery"; import { startSessionPresencePing } from "@/lib/stomp/sessionPresencePing"; import { subscribeUserRoomsQueue } from "@/lib/stomp/subscribe-user-rooms-queue"; import type { ForcedRoomExitReason } from "@/lib/stomp/user-room-queue"; -import { useStompConnectionStore } from "@/stores/stomp-connection-store"; +import { + STOMP_CONNECTION_ISSUE_MESSAGE, + useStompConnectionStore, +} from "@/stores/stomp-connection-store"; export type StompConnectionState = { client: Client | null; @@ -30,6 +39,7 @@ type LifecycleOpts = { eventRoomId: string, message?: string, ) => void; + notifyRoomAccessLost: (roomId: string) => void; subscribeToRoomTopics: (client: Client, roomId: string) => void; invalidateRoomTopics: () => void; teardownConnectedClient: () => void; @@ -40,15 +50,19 @@ type LifecycleOpts = { forcedExitConsumedRef: MutableRefObject; queryClientRef: MutableRefObject; setConnectionState: Dispatch>; + /** 배너의 '다시 시도'와 online 이벤트가 부르는 즉시 재연결. 시도 횟수를 처음부터 다시 센다 */ + retryConnectionRef: MutableRefObject<(() => void) | null>; }; /** * 로그인·경로 조건으로 STOMP 클라이언트 1개를 유지하고, 재연결 시 유저 방 큐 구독을 세팅합니다. + * 재연결 간격은 stompjs(`createStompClient`)가 맡고, 이 effect는 재연결 직전 세션 갱신·시도 횟수 제한·연결 상태 표시를 합니다. */ export function useStompClientLifecycleEffect({ stompEligible, suppressCloseRecoveryRef, notifyForcedRoomExit, + notifyRoomAccessLost, subscribeToRoomTopics, invalidateRoomTopics, teardownConnectedClient, @@ -59,6 +73,7 @@ export function useStompClientLifecycleEffect({ forcedExitConsumedRef, queryClientRef, setConnectionState, + retryConnectionRef, }: LifecycleOpts): void { useEffect(() => { if (!stompEligible) { @@ -72,36 +87,71 @@ export function useStompClientLifecycleEffect({ const stopPingRef: { current: (() => void) | null } = { current: null }; let effectActive = true; + // 첫 연결은 proxy가 세션을 확인했으므로, 한 번이라도 끊긴 뒤의 시도에서만 세션을 갱신한다 + let connectionDropped = false; + // 연결 시도(세션 확인~CONNECTED·close) 중에 다시 시도하면 연결이 둘로 갈라진다 + let attemptInFlight = false; + // 끊긴 뒤 연결에 성공하기 전까지의 재연결 시도 수 + let reconnectAttempts = 0; + // 서버는 구독 거부 등으로 ERROR를 보낸 뒤 연결을 닫는다(Spring 기본 동작) + let closingAfterStompError = false; + let stompErrorReconnectTimer: ReturnType | null = null; + let accessCheckRoomId: string | null = null; const stopAppPing = () => { stopPingRef.current?.(); stopPingRef.current = null; }; + const clearStompErrorReconnectTimer = () => { + if (stompErrorReconnectTimer === null) return; + clearTimeout(stompErrorReconnectTimer); + stompErrorReconnectTimer = null; + }; + const client = createStompClient(getStompBrokerURL()); clientRef.current = client; setConnectionState({ client, connected: false }); - const isActive = () => effectActive; - const isSuppressed = () => suppressCloseRecoveryRef.current; + const isActive = () => effectActive && !suppressCloseRecoveryRef.current; - const requestStompRecovery = () => { - if (!effectActive || suppressCloseRecoveryRef.current) return; - - stopAppPing(); - setConnectionState((prev) => ({ ...prev, connected: false })); - invalidateRoomTopics(); + // ERROR 프레임은 원인과 상관없이 같은 문구라 강퇴인지 서버 장애인지 알 수 없다. 그래서 방 접근을 HTTP로 확인한다 + const confirmRoomAccess = (roomId: string) => { + if (accessCheckRoomId === roomId) return; + accessCheckRoomId = roomId; + void validateRoomAccess(roomId).then((verdict) => { + accessCheckRoomId = null; + if (!isActive() || verdict !== "forbidden") return; + notifyRoomAccessLost(roomId); + }); + }; - void handleStompReconnect({ + client.beforeConnect = async () => { + attemptInFlight = true; + if (!connectionDropped) return; + reconnectAttempts += 1; + await refreshSessionBeforeReconnect({ client, queryClient: queryClientRef.current, - isSuppressed, isActive, }); }; + retryConnectionRef.current = () => { + if (!isActive() || attemptInFlight || client.connected) return; + clearStompErrorReconnectTimer(); + reconnectAttempts = 0; + // deactivate→activate는 예약된 재연결을 지우고 간격을 처음 값으로 되돌린 뒤 바로 시도한다 + void client.deactivate().then(() => { + if (isActive()) client.activate(); + }); + }; + client.onConnect = () => { if (!effectActive) return; + attemptInFlight = false; + closingAfterStompError = false; + reconnectAttempts = 0; useStompConnectionStore.getState().clearConnectionIssue(); setConnectionState({ client, connected: true }); forcedExitConsumedRef.current = false; @@ -121,31 +171,58 @@ export function useStompClientLifecycleEffect({ } }; - client.onDisconnect = () => { - if (!effectActive) return; - requestStompRecovery(); - }; - client.onStompError = () => { if (!effectActive) return; + closingAfterStompError = true; setConnectionState((prev) => ({ ...prev, connected: false })); + // 끊긴 동안 강퇴·방 삭제 알림을 놓친 방에 다시 구독하면 서버가 ERROR로 거부한다 + const rid = getResolvedRoomId()?.trim(); + if (rid) confirmRoomAccess(rid); }; client.onWebSocketClose = (event) => { - if (!effectActive) return; - if (suppressCloseRecoveryRef.current) return; + attemptInFlight = false; + if (!isActive()) return; if (event.code === STOMP_ACCESS_TOKEN_EXPIRED_CLOSE_CODE) { console.debug("[stomp] WebSocket closed: ACCESS_TOKEN_EXPIRED (4001)"); } - requestStompRecovery(); + connectionDropped = true; + stopAppPing(); + setConnectionState((prev) => ({ ...prev, connected: false })); + invalidateRoomTopics(); + + // 정해진 횟수를 모두 실패하면 재연결을 멈추고 배너로 알린다. 다시 시작은 '다시 시도'·online이 맡는다 + if (reconnectAttempts >= STOMP_MAX_RECONNECT_ATTEMPTS) { + closingAfterStompError = false; + clearStompErrorReconnectTimer(); + void client.deactivate(); + useStompConnectionStore + .getState() + .setConnectionIssue(STOMP_CONNECTION_ISSUE_MESSAGE); + return; + } + + // stompjs는 CONNECTED를 받을 때마다 간격을 처음 값으로 되돌려, 구독 오류로 닫히는 연결은 1초마다 다시 붙는다. + // 이 경우만 라이브러리 재연결을 멈추고 상한 간격 뒤에 다시 연결한다(문서 밖 보강) + if (closingAfterStompError) { + closingAfterStompError = false; + void client.deactivate(); + clearStompErrorReconnectTimer(); + stompErrorReconnectTimer = setTimeout(() => { + stompErrorReconnectTimer = null; + if (isActive()) client.activate(); + }, STOMP_MAX_RECONNECT_DELAY_MS); + } }; client.activate(); return () => { effectActive = false; + retryConnectionRef.current = null; + clearStompErrorReconnectTimer(); stopAppPing(); teardownConnectedClient(); }; @@ -154,11 +231,13 @@ export function useStompClientLifecycleEffect({ forcedExitConsumedRef, pathnameRef, queryClientRef, + retryConnectionRef, setConnectionState, userRoomsQueueUnsubRef, getResolvedRoomId, invalidateRoomTopics, notifyForcedRoomExit, + notifyRoomAccessLost, stompEligible, subscribeToRoomTopics, suppressCloseRecoveryRef, @@ -166,6 +245,20 @@ export function useStompClientLifecycleEffect({ ]); } +/** + * 브라우저가 네트워크 복귀를 알리면 다음 재연결 예약(최대 30초)을 기다리지 않고 바로 다시 연결합니다. + * 횟수를 다 써서 멈춘 뒤에도 다시 시작하며, 이때 시도 횟수를 처음부터 다시 셉니다. + */ +export function useStompOnlineRetryEffect( + retryConnectionRef: MutableRefObject<(() => void) | null>, +): void { + useEffect(() => { + const retry = () => retryConnectionRef.current?.(); + window.addEventListener("online", retry); + return () => window.removeEventListener("online", retry); + }, [retryConnectionRef]); +} + type RoomTopicsOpts = { /** `/home` 등 방 토픽을 미루는 경로 여부 — plan↔bookmark 탭 전환과 분리 */ roomTopicsDeferred: boolean; diff --git a/src/hooks/useChatMessageRead.test.tsx b/src/hooks/useChatMessageRead.test.tsx index e263a3c8..581a1a63 100644 --- a/src/hooks/useChatMessageRead.test.tsx +++ b/src/hooks/useChatMessageRead.test.tsx @@ -57,3 +57,26 @@ it("stable incoming handlers see committed panel/connection state and read dedup await hook.unmount(); client.clear(); }); +it("keeps markMessagesRead stable across reconnects so the history load is not restarted", async () => { + const client = new QueryClient(); + const rawMessagesRef = { current: [] as ServerChatMessage[] }; + const hook = hookHarness( + (roomId: string) => + useChatMessageRead({ roomId, panelOpen: true, rawMessagesRef }), + (child) => ( + {child} + ), + ); + state.connected = true; + await hook.render("a"); + const markMessagesRead = hook.current.markMessagesRead; + + state.connected = false; + await hook.render("a"); + state.connected = true; + await hook.render("a"); + + expect(hook.current.markMessagesRead).toBe(markMessagesRead); + await hook.unmount(); + client.clear(); +}); diff --git a/src/hooks/useChatMessageRead.ts b/src/hooks/useChatMessageRead.ts index 8a5cc78e..295adab3 100644 --- a/src/hooks/useChatMessageRead.ts +++ b/src/hooks/useChatMessageRead.ts @@ -29,15 +29,22 @@ export function useChatMessageRead({ const lastPublishedReadIdRef = useRef(null); const panelOpenRef = useRef(panelOpen); const markMessagesReadRef = useRef<(messageId?: string) => void>(() => {}); + // 연결 상태는 보낼 때 읽는다 — 함수가 연결마다 바뀌면 이 함수에 기대는 기록 조회가 다시 시작된다 + const stompRef = useRef({ client, connected }); useLayoutEffect(() => { panelOpenRef.current = panelOpen; }, [panelOpen]); + useLayoutEffect(() => { + stompRef.current = { client, connected }; + }, [client, connected]); + const markMessagesRead = useCallback( (messageId?: string) => { const rid = roomId?.trim(); - if (!rid || !client || !connected) return; + const stomp = stompRef.current; + if (!rid || !stomp.client || !stomp.connected) return; const id = messageId?.trim() || @@ -45,10 +52,10 @@ export function useChatMessageRead({ if (!id || id === lastPublishedReadIdRef.current) return; lastPublishedReadIdRef.current = id; - publishRoomMessageRead(client, rid, id); + publishRoomMessageRead(stomp.client, rid, id); optimisticallyClearRoomUnreadCount(queryClient, rid); }, - [client, connected, roomId, queryClient, rawMessagesRef], + [roomId, queryClient, rawMessagesRef], ); useLayoutEffect(() => { diff --git a/src/hooks/useChatMessages.recovery.test.tsx b/src/hooks/useChatMessages.recovery.test.tsx new file mode 100644 index 00000000..e695be58 --- /dev/null +++ b/src/hooks/useChatMessages.recovery.test.tsx @@ -0,0 +1,317 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { hookHarness } from "@/test/hook-harness"; +import { HttpError } from "@/lib/api/errors"; +import type { InitialRoomHistoryLoadResult } from "@/lib/chat/initialRoomHistory"; +import type { ServerChatMessage } from "@/types/chat"; +import { useChatMessages } from "./useChatMessages"; + +const mocks = vi.hoisted(() => ({ + history: vi.fn(), + initial: vi.fn(), + read: vi.fn(), + publish: vi.fn(), + stomp: { + connected: true, + handler: null as ((msg: ServerChatMessage) => void) | null, + }, +})); +vi.mock("@/hooks/useSessionUser", () => ({ + useSessionUser: () => ({ data: { id: 1 } }), +})); +vi.mock("@/hooks/useRooms", () => ({ + useRoomMembers: () => ({ data: { members: [] } }), +})); +vi.mock("@/hooks/useChatActions", () => ({ useChatActions: () => ({}) })); +vi.mock("@/contexts/StompContext", () => { + const client = { publish: mocks.publish }; + const setRoomChatMessageHandler = ( + fn: ((msg: ServerChatMessage) => void) | null, + ) => { + mocks.stomp.handler = fn; + }; + return { + useStompContext: () => ({ + client, + connected: mocks.stomp.connected, + setRoomChatMessageHandler, + }), + }; +}); +vi.mock("@/lib/api/rooms", () => ({ + getRoomMessageReadStatus: mocks.read, + getRoomMessages: mocks.history, +})); +vi.mock("@/lib/chat/initialRoomHistory", () => ({ + loadInitialRoomHistory: mocks.initial, + hasOlderHistoryPage: (count: number, size: number) => count >= size, +})); +vi.mock("@/lib/places/warmChatHistoryPlacePhotos", () => ({ + warmPlacePhotoQueriesFromChatHistory: vi.fn(), +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const ROOM = "room"; + +function message( + sequence: number, + overrides: Partial = {}, +): ServerChatMessage { + return { + roomId: ROOM, + id: `m${sequence}`, + senderId: 2, + messageType: "CHAT", + content: `m${sequence}`, + createdAt: new Date(Date.UTC(2026, 9, 25, 0, 0, sequence)).toISOString(), + sequence, + ...overrides, + }; +} + +function range(from: number, to: number): ServerChatMessage[] { + return Array.from({ length: to - from + 1 }, (_, i) => message(from + i)); +} + +function snapshot( + serverSlice: ServerChatMessage[], + overrides: Partial = {}, +): InitialRoomHistoryLoadResult { + return { + serverSlice, + hasMoreOlder: false, + hasMoreNewer: false, + lastReadMessageId: null, + warmHistory: [], + ...overrides, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const queryClient = new QueryClient(); +let hook: ReturnType; + +function mountHook() { + return hookHarness( + (roomId: string | null) => useChatMessages(roomId, { fetchHistory: true }), + (child) => ( + {child} + ), + ); +} + +const ids = () => hook.current.messages.map((m) => m.id); + +const lastPublishedRead = () => { + const body = mocks.publish.mock.calls[mocks.publish.mock.calls.length - 1]?.[0]?.body; + return body ? JSON.parse(body).lastReadMessageId : null; +}; + +async function receive(...msgs: ServerChatMessage[]) { + await act(async () => { + for (const msg of msgs) mocks.stomp.handler?.(msg); + }); +} + +async function reconnect() { + mocks.stomp.connected = false; + await hook.render(ROOM); + mocks.stomp.connected = true; + await hook.render(ROOM); + await hook.flush(); +} + +beforeEach(async () => { + mocks.stomp.connected = true; + mocks.read.mockResolvedValue({ lastReadMessageId: null }); + hook = mountHook(); +}); + +afterEach(async () => { + await hook.unmount(); + queryClient.clear(); + vi.resetAllMocks(); +}); + +describe("while the first history loads", () => { + it("keeps chat messages received before the snapshot arrives", async () => { + const load = deferred(); + mocks.initial.mockReturnValueOnce(load.promise); + await hook.render(ROOM); + + await receive(message(41)); + await act(async () => load.resolve(snapshot(range(39, 40)))); + + expect(ids()).toEqual(["m39", "m40", "m41"]); + expect(mocks.history).not.toHaveBeenCalled(); + }); + + it("keeps an AI request canceled during the load instead of the queued snapshot", async () => { + const queued = message(40, { + id: "ai", + messageType: "AI_REQUEST", + metadata: { aiStatus: "QUEUED", cancelable: "true" }, + }); + const canceled = { + ...queued, + metadata: { aiStatus: "CANCELED", cancelable: "false" }, + }; + const load = deferred(); + mocks.initial.mockReturnValueOnce(load.promise); + await hook.render(ROOM); + + await receive(canceled); + await act(async () => load.resolve(snapshot([message(39), queued]))); + + const ai = hook.current.messages.find((m) => m.id === "ai"); + expect(ai?.aiRequest?.aiStatus).toBe("CANCELED"); + }); + + it("finishes the load when the connection drops and returns, then catches up", async () => { + const load = deferred(); + mocks.initial.mockReturnValueOnce(load.promise); + await hook.render(ROOM); + + await reconnect(); + mocks.history.mockResolvedValueOnce([message(41)]); + await act(async () => load.resolve(snapshot(range(39, 40)))); + await hook.flush(); + + expect(mocks.initial).toHaveBeenCalledTimes(1); + expect(mocks.history).toHaveBeenCalledWith(ROOM, { + afterSequence: "40", + size: 200, + }); + expect(ids()).toEqual(["m39", "m40", "m41"]); + }); + + it("loads the history again after reconnecting when the load gave up", async () => { + mocks.read.mockRejectedValueOnce(new HttpError(400, "rejected")); + mocks.initial.mockResolvedValueOnce(snapshot(range(39, 40))); + await hook.render(ROOM); + expect(ids()).toEqual([]); + + await reconnect(); + + expect(ids()).toEqual(["m39", "m40"]); + }); +}); + +describe("after the history is loaded", () => { + beforeEach(async () => { + mocks.initial.mockResolvedValueOnce(snapshot(range(39, 40))); + await hook.render(ROOM); + }); + + it("catches up a missing sequence once and marks it read after it is shown", async () => { + const catchUp = deferred(); + mocks.history.mockReturnValueOnce(catchUp.promise); + + await receive(message(41), message(43)); + await receive(message(44)); + expect(lastPublishedRead()).toBe("m41"); + await act(async () => catchUp.resolve([message(42), message(43)])); + + expect(ids()).toEqual(["m39", "m40", "m41", "m42", "m43", "m44"]); + expect(mocks.history).toHaveBeenCalledTimes(1); + expect(mocks.history).toHaveBeenCalledWith(ROOM, { + afterSequence: "41", + size: 200, + }); + expect(lastPublishedRead()).toBe("m44"); + }); + + it("asks once after reconnecting even when nothing was missed", async () => { + mocks.history.mockResolvedValueOnce([]); + + await reconnect(); + + expect(mocks.history).toHaveBeenCalledTimes(1); + expect(mocks.history).toHaveBeenCalledWith(ROOM, { + afterSequence: "40", + size: 200, + }); + expect(ids()).toEqual(["m39", "m40"]); + expect(hook.current.readDividerPlacement).toBeNull(); + }); + + it("pages through a long disconnection and marks where the missed messages start", async () => { + mocks.history + .mockResolvedValueOnce(range(41, 240)) + .mockResolvedValueOnce(range(241, 440)) + .mockResolvedValueOnce(range(441, 450)); + + await reconnect(); + + expect(hook.current.messages).toHaveLength(412); + expect( + mocks.history.mock.calls.map(([, params]) => params.afterSequence), + ).toEqual(["40", "240", "440"]); + expect(hook.current.readDividerPlacement).toEqual({ + afterMessageId: "m40", + beforeFirst: false, + }); + expect(hook.current.scrollToAnchor?.anchorId).toBe("m40"); + }); + + it("does not append live messages to a middle window", async () => { + await hook.render(null); + mocks.initial.mockResolvedValueOnce( + snapshot(range(39, 40), { hasMoreNewer: true }), + ); + await hook.render(ROOM); + + await receive(message(41), message(40, { content: "edited" })); + + expect(ids()).toEqual(["m39", "m40"]); + expect(hook.current.messages[1]?.text).toBe("edited"); + expect(mocks.history).not.toHaveBeenCalled(); + }); + + it("stops catching up when the room rejects the request", async () => { + mocks.history.mockRejectedValue(new HttpError(403, "forbidden")); + + await receive(message(42)); + await hook.flush(); + + expect(mocks.history).toHaveBeenCalledTimes(1); + expect(ids()).toEqual(["m39", "m40"]); + }); + + it("catches up a room after coming back from another room", async () => { + mocks.initial.mockResolvedValueOnce(snapshot([])); + await hook.render("other"); + mocks.history.mockResolvedValueOnce([message(41)]); + + await hook.render(ROOM); + await hook.flush(); + + expect(mocks.history).toHaveBeenCalledWith(ROOM, { + afterSequence: "40", + size: 200, + }); + expect(ids()).toEqual(["m39", "m40", "m41"]); + }); +}); + +describe("in an empty room", () => { + it("loads the latest page instead of asking for afterSequence=0", async () => { + mocks.initial.mockResolvedValueOnce(snapshot([])); + await hook.render(ROOM); + mocks.history.mockResolvedValueOnce(range(1, 2)); + + await reconnect(); + + expect(mocks.history).toHaveBeenCalledWith(ROOM, { size: 15 }); + expect(ids()).toEqual(["m1", "m2"]); + }); +}); diff --git a/src/hooks/useChatMessages.ts b/src/hooks/useChatMessages.ts index 976e3089..0046ccfe 100644 --- a/src/hooks/useChatMessages.ts +++ b/src/hooks/useChatMessages.ts @@ -16,8 +16,10 @@ import { normalizeFetchedRoomMessages, oldestServerMessageByCreatedAt, parseFiniteNumber, + partitionLiveMessages, + replaceExistingServerMessages, serverMessageToChatMessage, - transientMessagesDuringRoomHistoryFetch, + tailCursorOf, } from "@/lib/chat"; import { useStompContext } from "@/contexts/StompContext"; import { useSessionUser } from "@/hooks/useSessionUser"; @@ -31,6 +33,10 @@ import { hasOlderHistoryPage, loadInitialRoomHistory, } from "@/lib/chat/initialRoomHistory"; +import { + fetchRoomMessagesAfter, + retryChatHistoryRequest, +} from "@/lib/chat/messageCatchUp"; import { CHAT_MESSAGE_PAGE_SIZE, EMPTY_READ_DIVIDER_PLACEMENT, @@ -44,11 +50,25 @@ export type UseChatMessagesOptions = { /** * `false`: 패널 닫힘 — GET 없음. STOMP만 유지. * `true`: 이 `roomId`에 대해 **이번 페이지 수명 내 아직 GET을 한 적 없을 때만** 히스토리 GET. - * (닫았다 다시 열 때는 같은 방이면 히스토리 GET 없이 read-status로 마커·스크롤만 복원.) + * (닫았다 다시 열 때는 같은 방이면 놓친 메시지만 따라잡고 read-status로 마커·스크롤을 복원.) */ fetchHistory: boolean; }; +/** + * 빈 번호를 따라잡는 이유 — 따라잡은 뒤 화면 처리가 다르다. + * - gap: 실시간 수신 중 번호가 비었다. 받은 메시지는 실시간 메시지처럼 읽음 처리한다 + * - reconnect: 끊긴 동안 놓쳤다. 새로 붙은 메시지 앞에 구분선을 두고 그 자리로 스크롤한다 + * - reopen: 다른 방에 다녀왔거나 패널을 다시 열었다. 읽음 경계를 서버 기준으로 복원한다 + */ +type CatchUpReason = "gap" | "reconnect" | "reopen"; + +const CATCH_UP_REASON_PRIORITY: Record = { + gap: 0, + reconnect: 1, + reopen: 2, +}; + function chatHistoryGateKey( uid: number | null | undefined, roomId: string, @@ -56,44 +76,35 @@ function chatHistoryGateKey( return `${uid ?? "anon"}:${roomId.trim()}`; } -function bumpRoomLastSequence( - map: Map, - rid: string, - msgs: readonly ServerChatMessage[], -) { - const mx = maxSequenceInMessages(msgs); - if (mx == null) return; - const prev = map.get(rid); - map.set(rid, prev != null ? Math.max(prev, mx) : mx); -} - -function applyBridgedSlice( - roomId: string, - bridged: Awaited>, - refs: { - hasMoreNewerByRoom: Map; - lastSequenceByRoom: Map; - }, - setters: { - setHasMoreNewer: (v: boolean) => void; - setRawMessages: (msgs: ServerChatMessage[]) => void; - }, - queryClient: ReturnType, -) { - if (bridged.bridgedHasMoreNewer == null) return; - - refs.hasMoreNewerByRoom.set(roomId, bridged.bridgedHasMoreNewer); - setters.setHasMoreNewer(bridged.bridgedHasMoreNewer); - setters.setRawMessages(bridged.slice); - - if (bridged.bridgedSlice?.length) { - bumpRoomLastSequence( - refs.lastSequenceByRoom, - roomId, - bridged.bridgedSlice, - ); - void warmPlacePhotoQueriesFromChatHistory(queryClient, bridged.bridgedSlice); +/** 패널 최초 기록 — 읽음 위치 앞 페이지에, 읽음 위치부터 이어지는 페이지를 붙인다 */ +async function loadRoomHistorySnapshot(roomId: string) { + const { lastReadMessageId } = await getRoomMessageReadStatus(roomId); + const out = await loadInitialRoomHistory( + roomId, + lastReadMessageId, + CHAT_MESSAGE_PAGE_SIZE, + ); + // 서버는 페이지를 sequence·문서 id 순으로 준다 — 화면은 작성 시각 순으로 맞춘다 + const serverSlice = mergeServerMessageLists(out.serverSlice); + if (!out.lastReadMessageId) { + return { ...out, slice: serverSlice, placementBeforeSlice: serverSlice }; } + + const bridged = await mergeSliceWithReadBridge( + roomId, + out.lastReadMessageId, + serverSlice, + serverSlice, + ); + return { + ...out, + slice: bridged.slice, + placementBeforeSlice: bridged.placementBeforeSlice, + hasMoreNewer: bridged.bridgedHasMoreNewer ?? out.hasMoreNewer, + warmHistory: bridged.bridgedSlice?.length + ? mergeServerMessageLists(out.warmHistory, bridged.bridgedSlice) + : out.warmHistory, + }; } export function useChatMessages( @@ -159,17 +170,27 @@ export function useChatMessages( const readBoundaryRestoreVersionRef = useRef(0); const activeRoomIdRef = useRef(""); const fetchHistoryRef = useRef(fetchHistory); - const hasObservedConnectedRef = useRef(false); - const disconnectedAfterConnectionRef = useRef(false); + /** 마운트 때 이미 연결돼 있었으면 구독이 최초 기록 조회보다 먼저다 — 그 뒤의 재연결만 따라잡는다 */ + const wasConnectedRef = useRef(connected); const historyFetchedRoomIdsRef = useRef>(new Set()); const hasMoreByRoomRef = useRef>(new Map()); const hasMoreNewerByRoomRef = useRef>(new Map()); - const lastSequenceByRoomRef = useRef>(new Map()); + /** 목록 끝까지 빠짐없이 반영한 마지막 sequence. 목록이 최신 끝일 때만 둔다 */ + const cursorByRoomRef = useRef>(new Map()); const lastReadMarkerByRoomRef = useRef>(new Map()); const readDividerPlacementByRoomRef = useRef>( new Map(), ); - const recoveringSequenceRoomsRef = useRef>(new Set()); + /** + * 목록 끝을 바꾸는 조회(최초 기록·최신으로 이동·아래 페이지·따라잡기)는 마지막에 시작한 하나만 반영한다. + * 조회 중 받은 실시간 메시지는 모았다가, 조회 결과를 반영한 뒤 같은 커서 규칙으로 처리한다. + */ + const tailSyncTokenRef = useRef(0); + const liveBufferRef = useRef(null); + const pendingCatchUpRef = useRef(null); + const requestCatchUpRef = useRef<(rid: string, reason: CatchUpReason) => void>( + () => {}, + ); const isFetchingOlderRef = useRef(false); const isFetchingNewerRef = useRef(false); const rawMessagesRef = useRef([]); @@ -179,6 +200,8 @@ export function useChatMessages( roomLifetimeRef.current += 1; isFetchingOlderRef.current = false; isFetchingNewerRef.current = false; + liveBufferRef.current = null; + pendingCatchUpRef.current = null; return () => { roomLifetimeRef.current += 1; }; }, [roomId, userId]); @@ -324,22 +347,28 @@ export function useChatMessages( ); if (!isCurrentRequest()) return; - applyBridgedSlice( - targetRoomId, - bridged, - { - hasMoreNewerByRoom: hasMoreNewerByRoomRef.current, - lastSequenceByRoom: lastSequenceByRoomRef.current, - }, - { setHasMoreNewer, setRawMessages }, - queryClient, - ); + const bridgedSlice = bridged.bridgedSlice; + const bridgedHasMoreNewer = bridged.bridgedHasMoreNewer; + if (bridgedSlice && bridgedHasMoreNewer != null) { + hasMoreNewerByRoomRef.current.set(rid, bridgedHasMoreNewer); + setHasMoreNewer(bridgedHasMoreNewer); + // 그사이 붙은 실시간 메시지를 지우지 않게 현재 목록에 합친다 + setRawMessages((prev) => mergeServerMessageLists(prev, bridgedSlice)); + const last = maxSequenceInMessages(bridgedSlice); + const cursor = cursorByRoomRef.current.get(rid); + if (bridgedHasMoreNewer) { + cursorByRoomRef.current.delete(rid); + } else if (last != null) { + cursorByRoomRef.current.set(rid, Math.max(cursor ?? last, last)); + } + void warmPlacePhotoQueriesFromChatHistory(queryClient, bridgedSlice); + } const placement = resolveReadDividerPlacement( bridged.slice, lastReadMessageId, bridged.placementBeforeSlice, - hasMoreNewerByRoomRef.current.get(targetRoomId) === true, + hasMoreNewerByRoomRef.current.get(rid) === true, ); applySessionReadBoundary(rid, lastReadMessageId, placement); @@ -370,6 +399,228 @@ export function useChatMessages( ], ); + /** 목록 끝을 바꾸는 조회를 시작한다. 돌려받은 함수가 거짓이면 그 결과는 버린다 */ + const beginTailSync = useCallback(() => { + tailSyncTokenRef.current += 1; + liveBufferRef.current ??= []; + const token = tailSyncTokenRef.current; + const lifetime = roomLifetimeRef.current; + return () => + tailSyncTokenRef.current === token && + roomLifetimeRef.current === lifetime; + }, []); + + const applyLiveMessages = useCallback( + (rid: string, msgs: readonly ServerChatMessage[], catchUpOnGap: boolean) => { + // 첫 기록을 받기 전에는 그 조회가 이 메시지까지 가져온다 + if ( + msgs.length === 0 || + !historyFetchedRoomIdsRef.current.has(chatHistoryGateKey(userId, rid)) + ) { + return; + } + + const { appended, updates, cursor, hasGap } = partitionLiveMessages(msgs, { + cursor: cursorByRoomRef.current.get(rid), + atTail: hasMoreNewerByRoomRef.current.get(rid) !== true, + list: rawMessagesRef.current, + }); + if (cursor != null) cursorByRoomRef.current.set(rid, cursor); + if (appended.length > 0 || updates.length > 0) { + setRawMessages((prev) => { + const updated = replaceExistingServerMessages(prev, updates); + return appended.length > 0 + ? mergeServerMessageLists(updated, appended) + : updated; + }); + } + if (hasGap && catchUpOnGap) requestCatchUpRef.current(rid, "gap"); + + const newest = appended[appended.length - 1]; + if (newest) onIncomingMessage(newest, true); + }, + [userId, onIncomingMessage], + ); + + /** 조회 결과를 반영한 뒤 모아 둔 실시간 메시지를 처리하고, 미뤄 둔 따라잡기를 잇는다 */ + const finishTailSync = useCallback( + (rid: string, succeeded: boolean) => { + const buffered = liveBufferRef.current ?? []; + liveBufferRef.current = null; + // 조회가 실패했으면 빈 번호를 바로 다시 요청하지 않는다 — 다음 수신·재연결이 다시 맞춘다 + applyLiveMessages(rid, buffered, succeeded); + const pending = pendingCatchUpRef.current; + pendingCatchUpRef.current = null; + if (pending) requestCatchUpRef.current(rid, pending); + }, + [applyLiveMessages], + ); + + const showCaughtUpMessages = useCallback( + ( + rid: string, + reason: CatchUpReason, + appended: readonly ServerChatMessage[], + newestBefore: ServerChatMessage | null, + ) => { + if (reason === "reopen") { + void restoreSessionReadBoundary(rid); + return; + } + const newest = appended[appended.length - 1]; + if (!newest) return; + if (reason === "gap") { + onIncomingMessage(newest, true); + return; + } + // 끊긴 동안 온 메시지는 읽지 않은 메시지로 보여 준다 + if (!newestBefore || !fetchHistoryRef.current) return; + applySessionReadBoundary(rid, newestBefore.id, { + afterMessageId: newestBefore.id, + beforeFirst: false, + }); + publishScrollToAnchor(newestBefore.id); + }, + [ + restoreSessionReadBoundary, + onIncomingMessage, + applySessionReadBoundary, + publishScrollToAnchor, + ], + ); + + const requestCatchUp = useCallback( + (rid: string, reason: CatchUpReason) => { + if (liveBufferRef.current) { + // 목록 끝을 바꾸는 다른 조회가 진행 중이다 — 끝난 뒤(finishTailSync) 이어서 따라잡는다 + const pending = pendingCatchUpRef.current; + if ( + !pending || + CATCH_UP_REASON_PRIORITY[reason] > CATCH_UP_REASON_PRIORITY[pending] + ) { + pendingCatchUpRef.current = reason; + } + return; + } + if (!historyFetchedRoomIdsRef.current.has(chatHistoryGateKey(userId, rid))) { + return; + } + if (hasMoreNewerByRoomRef.current.get(rid) === true) { + // 중간 창은 아래 페이지 조회가 최신 끝까지 맞춘다 + if (reason === "reopen") void restoreSessionReadBoundary(rid); + return; + } + + const isCurrent = beginTailSync(); + const cursor = cursorByRoomRef.current.get(rid); + const newestBefore = newestServerMessageByCreatedAt(rawMessagesRef.current); + void fetchRoomMessagesAfter(rid, cursor, isCurrent) + .then((rows) => { + if (!isCurrent()) return; + const known = new Set(rawMessagesRef.current.map((m) => m.id)); + const appended = rows.filter((m) => !known.has(m.id)); + setRawMessages((prev) => mergeServerMessageLists(prev, rows)); + const last = maxSequenceInMessages(rows); + if (last != null) { + cursorByRoomRef.current.set(rid, Math.max(cursor ?? last, last)); + } + if (cursor === 0) { + // 빈 방에서 최신 페이지를 받았다 — 그 앞 기록은 위로 스크롤해 받는다 + const more = hasOlderHistoryPage(rows.length, CHAT_MESSAGE_PAGE_SIZE); + hasMoreByRoomRef.current.set(rid, more); + setHasMore(more); + } + void warmPlacePhotoQueriesFromChatHistory(queryClient, rows); + // 모아 둔 실시간 메시지가 더 새것이다 — 읽음 위치가 뒤로 가지 않게 따라잡은 메시지를 먼저 처리한다 + showCaughtUpMessages(rid, reason, appended, newestBefore); + finishTailSync(rid, true); + }) + .catch(() => { + // 재시도 뒤에도 실패하면 커서를 그대로 둔다 — 다음 빈 번호 수신·재연결 때 같은 커서로 다시 받는다 + if (!isCurrent()) return; + finishTailSync(rid, false); + if (reason === "reopen") void restoreSessionReadBoundary(rid); + }); + }, + [ + userId, + queryClient, + beginTailSync, + finishTailSync, + showCaughtUpMessages, + restoreSessionReadBoundary, + ], + ); + + useLayoutEffect(() => { + requestCatchUpRef.current = requestCatchUp; + }, [requestCatchUp]); + + const startHistoryLoad = useCallback( + (targetRoomId: string) => { + const rid = targetRoomId.trim(); + const isCurrent = beginTailSync(); + void retryChatHistoryRequest( + () => loadRoomHistorySnapshot(targetRoomId), + isCurrent, + ) + .then((out) => { + if (!isCurrent()) return; + hasMoreByRoomRef.current.set(rid, out.hasMoreOlder); + hasMoreNewerByRoomRef.current.set(rid, out.hasMoreNewer); + setHasMore(out.hasMoreOlder); + setHasMoreNewer(out.hasMoreNewer); + + const placement = out.lastReadMessageId + ? resolveReadDividerPlacement( + out.slice, + out.lastReadMessageId, + out.placementBeforeSlice, + out.hasMoreNewer, + ) + : EMPTY_READ_DIVIDER_PLACEMENT; + applySessionReadBoundary(rid, out.lastReadMessageId, placement); + + setRawMessages(out.slice); + // 최신 끝까지 받았으면 그 마지막 번호 다음부터 실시간 메시지를 붙인다 + const cursor = out.hasMoreNewer ? undefined : tailCursorOf(out.slice); + if (cursor == null) cursorByRoomRef.current.delete(rid); + else cursorByRoomRef.current.set(rid, cursor); + historyFetchedRoomIdsRef.current.add(chatHistoryGateKey(userId, rid)); + void warmPlacePhotoQueriesFromChatHistory(queryClient, out.warmHistory); + + const newest = newestServerMessageByCreatedAt(out.slice); + if (newest && !out.hasMoreNewer) { + resetReadDedup(); + markMessagesRead(newest.id); + } + + if ( + out.lastReadMessageId && + canRenderReadDivider(out.slice, placement) + ) { + publishScrollToAnchor(out.lastReadMessageId); + } + finishTailSync(rid, true); + }) + .catch(() => { + // gate를 세우지 않는다 — 재연결되거나 패널을 다시 열 때 다시 받는다 + if (isCurrent()) finishTailSync(rid, false); + }); + }, + [ + userId, + queryClient, + beginTailSync, + finishTailSync, + applySessionReadBoundary, + canRenderReadDivider, + publishScrollToAnchor, + markMessagesRead, + resetReadDedup, + ], + ); + useEffect(() => { if (!roomId) { readBoundaryRestoreVersionRef.current += 1; @@ -378,10 +629,9 @@ export function useChatMessages( historyFetchedRoomIdsRef.current.clear(); hasMoreByRoomRef.current.clear(); hasMoreNewerByRoomRef.current.clear(); - lastSequenceByRoomRef.current.clear(); + cursorByRoomRef.current.clear(); lastReadMarkerByRoomRef.current.clear(); readDividerPlacementByRoomRef.current.clear(); - recoveringSequenceRoomsRef.current.clear(); return; } @@ -397,138 +647,43 @@ export function useChatMessages( prevRoomForHistoryGateRef.current = roomId; if (userId == null) return; - const gateKey = chatHistoryGateKey(userId, roomId); - const ridTrim = roomId.trim(); - if (historyFetchedRoomIdsRef.current.has(gateKey)) { - if (reopened) void restoreSessionReadBoundary(roomId); + const rid = roomId.trim(); + if (historyFetchedRoomIdsRef.current.has(chatHistoryGateKey(userId, rid))) { + if (!reopened) return; + // 다른 방에 있던 동안은 이 방 메시지를 구독하지 않았다 — 다시 구독(부모 effect)을 보낸 뒤 따라잡는다 + queueMicrotask(() => { + if (activeRoomIdRef.current === rid) requestCatchUp(rid, "reopen"); + }); return; } - - let cancelled = false; - const restoreVersion = ++readBoundaryRestoreVersionRef.current; - const isCurrentRequest = () => - !cancelled && - readBoundaryRestoreVersionRef.current === restoreVersion && - fetchHistoryRef.current && - activeRoomIdRef.current === ridTrim; - - void (async () => { - try { - const { lastReadMessageId } = await getRoomMessageReadStatus(roomId); - if (!isCurrentRequest()) return; - - const out = await loadInitialRoomHistory( - roomId, - lastReadMessageId, - CHAT_MESSAGE_PAGE_SIZE, - ); - if (!isCurrentRequest()) return; - - let slice = mergeServerMessageLists( - out.serverSlice, - transientMessagesDuringRoomHistoryFetch(rawMessagesRef.current), - ); - let hasMoreNewer = out.hasMoreNewer; - let warmHistory = [...out.warmHistory]; - let placementBeforeSlice: readonly ServerChatMessage[] = out.serverSlice; - - if (out.lastReadMessageId) { - const bridged = await mergeSliceWithReadBridge( - roomId, - out.lastReadMessageId, - slice, - out.serverSlice, - ); - if (!isCurrentRequest()) return; - slice = bridged.slice; - placementBeforeSlice = bridged.placementBeforeSlice; - if (bridged.bridgedHasMoreNewer != null) { - hasMoreNewer = bridged.bridgedHasMoreNewer; - } - if (bridged.bridgedSlice?.length) { - warmHistory = mergeServerMessageLists( - warmHistory, - bridged.bridgedSlice, - ); - } - } - - hasMoreByRoomRef.current.set(roomId, out.hasMoreOlder); - hasMoreNewerByRoomRef.current.set(roomId, hasMoreNewer); - setHasMore(out.hasMoreOlder); - setHasMoreNewer(hasMoreNewer); - - const placement = out.lastReadMessageId - ? resolveReadDividerPlacement( - slice, - out.lastReadMessageId, - placementBeforeSlice, - hasMoreNewer, - ) - : EMPTY_READ_DIVIDER_PLACEMENT; - applySessionReadBoundary(ridTrim, out.lastReadMessageId, placement); - - setRawMessages(slice); - bumpRoomLastSequence(lastSequenceByRoomRef.current, roomId, slice); - historyFetchedRoomIdsRef.current.add(gateKey); - void warmPlacePhotoQueriesFromChatHistory(queryClient, warmHistory); - - const newest = newestServerMessageByCreatedAt(slice); - if (newest && !hasMoreNewer) { - resetReadDedup(); - markMessagesRead(newest.id); - } - - if ( - out.lastReadMessageId && - canRenderReadDivider(slice, placement) - ) { - publishScrollToAnchor(out.lastReadMessageId); - } - } catch { - /* 실패 시 Set에 넣지 않음 → 다음 패널 오픈 시 재시도 */ - } - })(); - - return () => { - cancelled = true; - }; - }, [ - roomId, - fetchHistory, - queryClient, - userId, - markMessagesRead, - resetReadDedup, - publishScrollToAnchor, - applySessionReadBoundary, - canRenderReadDivider, - restoreSessionReadBoundary, - ]); + // 진행 중인 최초 조회가 있으면 그 결과를 쓴다 + if (!liveBufferRef.current) startHistoryLoad(roomId); + }, [roomId, fetchHistory, userId, requestCatchUp, startHistoryLoad]); useEffect(() => { - if (!connected) { - if (hasObservedConnectedRef.current) { - disconnectedAfterConnectionRef.current = true; - } - return; - } - - if (!hasObservedConnectedRef.current) { - hasObservedConnectedRef.current = true; - return; - } + const wasConnected = wasConnectedRef.current; + wasConnectedRef.current = connected; + if (!connected || wasConnected || !roomId || userId == null) return; - if (!disconnectedAfterConnectionRef.current) return; - disconnectedAfterConnectionRef.current = false; - - if (!fetchHistory || !roomId) return; - queueMicrotask(() => void restoreSessionReadBoundary(roomId)); - }, [connected, fetchHistory, roomId, restoreSessionReadBoundary]); + const rid = roomId.trim(); + // 다시 구독한 토픽은 끊긴 동안의 메시지를 주지 않는다 — 구독(부모 effect)을 보낸 뒤 따라잡는다 + queueMicrotask(() => { + if (activeRoomIdRef.current !== rid) return; + if ( + liveBufferRef.current || + historyFetchedRoomIdsRef.current.has(chatHistoryGateKey(userId, rid)) + ) { + requestCatchUp(rid, "reconnect"); + } else if (fetchHistoryRef.current) { + // 끊긴 동안 최초 기록 조회가 끝내 실패했다 — 연결이 돌아온 지금 다시 받는다 + startHistoryLoad(roomId); + } + }); + }, [connected, roomId, userId, requestCatchUp, startHistoryLoad]); const fetchOlderMessages = useCallback(() => { - const rid = roomId; - if (!rid || activeRoomIdRef.current !== rid.trim()) return; + const rid = roomId?.trim(); + if (!rid || activeRoomIdRef.current !== rid) return; if (isFetchingOlderRef.current) return; if (hasMoreByRoomRef.current.get(rid) === false) return; @@ -555,7 +710,6 @@ export function useChatMessages( setRawMessages((prev) => mergeServerMessageLists(normalizedHistory, prev), ); - bumpRoomLastSequence(lastSequenceByRoomRef.current, rid, normalizedHistory); void warmPlacePhotoQueriesFromChatHistory( queryClient, normalizedHistory, @@ -572,8 +726,8 @@ export function useChatMessages( }, [roomId, queryClient]); const fetchNewerMessages = useCallback(() => { - const rid = roomId; - if (!rid || activeRoomIdRef.current !== rid.trim()) return; + const rid = roomId?.trim(); + if (!rid || activeRoomIdRef.current !== rid) return; if (isFetchingNewerRef.current) return; if (hasMoreNewerByRoomRef.current.get(rid) !== true) return; @@ -583,13 +737,14 @@ export function useChatMessages( isFetchingNewerRef.current = true; const lifetime = roomLifetimeRef.current; setIsFetchingNewer(true); + const isCurrent = beginTailSync(); void getRoomMessages(rid, { afterId: newest.id, size: CHAT_MESSAGE_PAGE_SIZE, }) .then((history) => { - if (roomLifetimeRef.current !== lifetime) return; + if (!isCurrent()) return; const normalizedHistory = normalizeFetchedRoomMessages(history); const more = hasOlderHistoryPage( normalizedHistory.length, @@ -600,57 +755,40 @@ export function useChatMessages( setRawMessages((prev) => mergeServerMessageLists(prev, normalizedHistory), ); - bumpRoomLastSequence(lastSequenceByRoomRef.current, rid, normalizedHistory); + if (!more) { + // 최신 끝에 닿았다 — 이 페이지의 마지막 번호 다음부터 실시간 메시지를 붙인다 + const last = maxSequenceInMessages([ + ...rawMessagesRef.current, + ...normalizedHistory, + ]); + if (last == null) cursorByRoomRef.current.delete(rid); + else cursorByRoomRef.current.set(rid, last); + } void warmPlacePhotoQueriesFromChatHistory( queryClient, normalizedHistory, ); + finishTailSync(rid, true); }) .catch(() => { - /* 유지: hasMoreNewer 그대로, 다음 스크롤에서 재시도 가능 */ + // 유지: hasMoreNewer 그대로, 다음 스크롤에서 다시 받는다 + if (isCurrent()) finishTailSync(rid, false); }) .finally(() => { if (roomLifetimeRef.current !== lifetime) return; isFetchingNewerRef.current = false; setIsFetchingNewer(false); }); - }, [roomId, queryClient]); - - const recoverMissingMessages = useCallback( - (rid: string, afterSeq: number) => { - if (recoveringSequenceRoomsRef.current.has(rid)) return; - recoveringSequenceRoomsRef.current.add(rid); - const lifetime = roomLifetimeRef.current; - - void getRoomMessages(rid, { - afterSequence: String(afterSeq), - size: CHAT_MESSAGE_PAGE_SIZE, - }) - .then((rows) => { - if (roomLifetimeRef.current !== lifetime) return; - const normalized = normalizeFetchedRoomMessages(rows); - setRawMessages((prev) => mergeServerMessageLists(prev, normalized)); - bumpRoomLastSequence(lastSequenceByRoomRef.current, rid, normalized); - void warmPlacePhotoQueriesFromChatHistory(queryClient, normalized); - }) - .catch(() => { - /* 다음 STOMP 수신 시 재감지 */ - }) - .finally(() => { - recoveringSequenceRoomsRef.current.delete(rid); - }); - }, - [queryClient], - ); + }, [roomId, queryClient, beginTailSync, finishTailSync]); const jumpToLatest = useCallback(() => { - const rid = roomId; - if (!rid || activeRoomIdRef.current !== rid.trim()) return; + const rid = roomId?.trim(); + if (!rid || activeRoomIdRef.current !== rid) return; - const lifetime = roomLifetimeRef.current; + const isCurrent = beginTailSync(); void getRoomMessages(rid, { size: CHAT_MESSAGE_PAGE_SIZE }) .then((history) => { - if (roomLifetimeRef.current !== lifetime) return; + if (!isCurrent()) return; const normalized = normalizeFetchedRoomMessages(history); const more = hasOlderHistoryPage( normalized.length, @@ -658,22 +796,29 @@ export function useChatMessages( ); hasMoreByRoomRef.current.set(rid, more); hasMoreNewerByRoomRef.current.set(rid, false); - clearSessionReadBoundary(rid.trim()); + const cursor = tailCursorOf(normalized); + if (cursor == null) cursorByRoomRef.current.delete(rid); + else cursorByRoomRef.current.set(rid, cursor); + clearSessionReadBoundary(rid); setHasMore(more); setHasMoreNewer(false); - lastSequenceByRoomRef.current.delete(rid); - setRawMessages([]); - queueMicrotask(() => { - if (roomLifetimeRef.current !== lifetime) return; - setRawMessages(normalized); - bumpRoomLastSequence(lastSequenceByRoomRef.current, rid, normalized); - void warmPlacePhotoQueriesFromChatHistory(queryClient, normalized); - }); + setRawMessages(mergeServerMessageLists(normalized)); + publishScrollToAnchor(undefined); + void warmPlacePhotoQueriesFromChatHistory(queryClient, normalized); + finishTailSync(rid, true); }) .catch(() => { - /* noop */ + // 중간 창을 그대로 둔다 — 버튼을 다시 누르면 다시 받는다 + if (isCurrent()) finishTailSync(rid, false); }); - }, [roomId, queryClient, clearSessionReadBoundary]); + }, [ + roomId, + queryClient, + beginTailSync, + finishTailSync, + clearSessionReadBoundary, + publishScrollToAnchor, + ]); useEffect(() => { if (!roomId) { @@ -681,44 +826,18 @@ export function useChatMessages( return; } + const rid = roomId.trim(); const handler = (msg: ServerChatMessage) => { - const rid = roomId.trim(); if (activeRoomIdRef.current !== rid) return; - const lastSeq = lastSequenceByRoomRef.current.get(rid); - const seq = msg.sequence; - const isNew = !rawMessagesRef.current.some((m) => m.id === msg.id); - - setRawMessages((prev) => { - const i = prev.findIndex((m) => m.id === msg.id); - if (i < 0) return [...prev, msg]; - const next = [...prev]; - next[i] = msg; - return next; - }); - - const atTail = hasMoreNewerByRoomRef.current.get(rid) !== true; - if ( - atTail && - seq != null && - lastSeq != null && - seq > lastSeq + 1 && - !recoveringSequenceRoomsRef.current.has(rid) - ) { - recoverMissingMessages(rid, lastSeq); + if (liveBufferRef.current) { + liveBufferRef.current.push(msg); + return; } - - if (seq != null) { - lastSequenceByRoomRef.current.set( - rid, - Math.max(lastSeq ?? seq, seq), - ); - } - - onIncomingMessage(msg, isNew); + applyLiveMessages(rid, [msg], true); }; setRoomChatMessageHandler(handler); return () => setRoomChatMessageHandler(null); - }, [roomId, setRoomChatMessageHandler, onIncomingMessage, recoverMissingMessages]); + }, [roomId, setRoomChatMessageHandler, applyLiveMessages]); const rid = roomId?.trim() ?? ""; const effectiveReadMarkerMessageId = diff --git a/src/hooks/useExpenseInvalidation.test.ts b/src/hooks/useExpenseInvalidation.test.ts index fcdc5797..ff32c7c3 100644 --- a/src/hooks/useExpenseInvalidation.test.ts +++ b/src/hooks/useExpenseInvalidation.test.ts @@ -3,7 +3,7 @@ import { beforeEach, expect, it, vi } from "vitest"; const mock = vi.hoisted(() => ({ client: null as unknown, mutation: vi.fn(), - hydrate: vi.fn(async () => []), + refresh: vi.fn(async () => []), })); vi.mock("@tanstack/react-query", async (importOriginal) => ({ ...(await importOriginal()), @@ -12,7 +12,7 @@ vi.mock("@tanstack/react-query", async (importOriginal) => ({ })); vi.mock("@/lib/rooms", async (importOriginal) => ({ ...(await importOriginal()), - hydrateRoomSchedulesFromServer: mock.hydrate, + refreshRoomSchedules: mock.refresh, syncRoomDetailFromServer: vi.fn(), })); vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ @@ -32,7 +32,7 @@ beforeEach(() => { client = new QueryClient(); mock.client = client; mock.mutation.mockReset(); - mock.hydrate.mockResolvedValue([]); + mock.refresh.mockResolvedValue([]); for (const suffix of ["list", "summary"]) client.setQueryData(["room-expenses", "r", suffix], []); client.setQueryData(["room-expenses", "other", "list"], []); diff --git a/src/hooks/usePlanItineraryReorder.analytics.test.tsx b/src/hooks/usePlanItineraryReorder.analytics.test.tsx index 03f1e9f0..452d1cff 100644 --- a/src/hooks/usePlanItineraryReorder.analytics.test.tsx +++ b/src/hooks/usePlanItineraryReorder.analytics.test.tsx @@ -12,6 +12,10 @@ const api = vi.hoisted(() => ({ reorder: vi.fn() })); vi.mock("@/lib/client-env", () => ({ clientEnv: { NEXT_PUBLIC_API_BASE_URL: "https://api.example.test" } })); vi.mock("@/lib/api/rooms", async (original) => ({ ...await original(), reorderScheduleItem: api.reorder })); vi.mock("@/lib/analytics/track", () => ({ AnalyticsEvents: { reorderItinerary: "reorder_itinerary" }, trackAnalyticsEvent: vi.fn() })); +const toastError = vi.hoisted(() => vi.fn()); +vi.mock("sonner", () => ({ toast: { error: toastError } })); +const stomp = vi.hoisted(() => ({ connected: true })); +vi.mock("@/contexts/StompContext", () => ({ useStompContext: () => stomp })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); it("attributes a completed drag reorder to the request's room after the hook switches rooms", async () => { @@ -59,3 +63,88 @@ it("attributes a completed drag reorder to the request's room after the hook swi client.clear(); } }); + +it("saves the dragged item at the dropped index even when the day's list changed during the drag", async () => { + api.reorder.mockClear(); + type Place = { id: string; itemId: number; title: string }; + const placesAtStart: Place[] = [{ id: "item-1", itemId: 1, title: "One" }, { id: "item-2", itemId: 2, title: "Two" }]; + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + const hook = hookHarness((places: Place[]) => usePlanItineraryReorder({ roomId: "room-a", scheduleId: 1, places }), + (child) => {child}); + const data = new Map(); + const dataTransfer = { + setData: (key: string, value: string) => data.set(key, value), + getData: (key: string) => data.get(key) ?? "", + setDragImage: () => {}, + get types() { return Array.from(data.keys()); }, + } as unknown as DataTransfer; + const rows = [0, 1].map((index) => { + const row = document.createElement("div"); + row.getBoundingClientRect = () => ({ top: index * 100, left: 0, width: 200, height: 100, bottom: index * 100 + 100, right: 200, x: 0, y: index * 100, toJSON: () => ({}) }); + return row; + }); + const event = { dataTransfer, currentTarget: rows[0], clientX: 10, clientY: 10, preventDefault: () => {} }; + try { + await hook.render(placesAtStart); + rows.forEach((row, index) => hook.current.getRowProps(index, false).ref(row)); + await act(async () => hook.current.getRowProps(0, false).placeCardDragProps.onDragStart({ ...event, nativeEvent: event } as unknown as DragEvent)); + await act(async () => { + hook.current.listContainerProps.onDragOver!({ ...event, clientY: 190 } as unknown as DragEvent); + await new Promise((yes) => requestAnimationFrame(yes)); + }); + // 드래그하는 동안 다른 사람이 맨 앞에 장소를 추가했다 + await hook.render([{ id: "item-3", itemId: 3, title: "Three" }, ...placesAtStart]); + await act(async () => { await hook.current.listContainerProps.onDrop!(event as unknown as DragEvent); }); + + expect(api.reorder).toHaveBeenCalledWith("room-a", 1, 1, { newOrderIndex: 1 }); + } finally { + document.dispatchEvent(new Event("dragend")); + await hook.unmount(); + client.clear(); + } +}); + +it("does not save a drop made while the connection is down", async () => { + api.reorder.mockClear(); + toastError.mockClear(); + const places = [{ id: "item-1", itemId: 1, title: "One" }, { id: "item-2", itemId: 2, title: "Two" }]; + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + const hook = hookHarness((connected: boolean) => { + stomp.connected = connected; + return usePlanItineraryReorder({ roomId: "room-a", scheduleId: 1, places }); + }, (child) => {child}); + const data = new Map(); + const dataTransfer = { + setData: (key: string, value: string) => data.set(key, value), + getData: (key: string) => data.get(key) ?? "", + setDragImage: () => {}, + get types() { return Array.from(data.keys()); }, + } as unknown as DataTransfer; + const rows = [0, 1].map((index) => { + const row = document.createElement("div"); + row.getBoundingClientRect = () => ({ top: index * 100, left: 0, width: 200, height: 100, bottom: index * 100 + 100, right: 200, x: 0, y: index * 100, toJSON: () => ({}) }); + return row; + }); + const event = { dataTransfer, currentTarget: rows[0], clientX: 10, clientY: 10, preventDefault: () => {} }; + try { + await hook.render(true); + rows.forEach((row, index) => hook.current.getRowProps(index, false).ref(row)); + await act(async () => hook.current.getRowProps(0, false).placeCardDragProps.onDragStart({ ...event, nativeEvent: event } as unknown as DragEvent)); + await act(async () => { + hook.current.listContainerProps.onDragOver!({ ...event, clientY: 190 } as unknown as DragEvent); + await new Promise((yes) => requestAnimationFrame(yes)); + }); + // 드래그하는 동안 연결이 끊겼다 + await hook.render(false); + expect(hook.current.getRowProps(1, false).placeCardDragProps.dragDisabled).toBe(true); + await act(async () => { await hook.current.listContainerProps.onDrop!(event as unknown as DragEvent); }); + + expect(api.reorder).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledOnce(); + } finally { + stomp.connected = true; + document.dispatchEvent(new Event("dragend")); + await hook.unmount(); + client.clear(); + } +}); diff --git a/src/hooks/usePlanItineraryReorder.ts b/src/hooks/usePlanItineraryReorder.ts index 0e682e0f..c248a8a2 100644 --- a/src/hooks/usePlanItineraryReorder.ts +++ b/src/hooks/usePlanItineraryReorder.ts @@ -10,6 +10,7 @@ import { import { toast } from "sonner"; import { useDragAutoScroll } from "@/hooks/useDragAutoScroll"; +import { useScheduleResyncPending } from "@/hooks/useScheduleResyncPending"; import { useMoveScheduleItemToSchedule, useReorderScheduleItem, @@ -24,9 +25,11 @@ import { executePlanItemCrossDayMove, getPlanItemRowReorderStyle, isPlanItemDrag, + PLAN_ORDER_RESYNCING_MESSAGE, type PlanItemRowPreviewMode, PlanItemRowLayoutSnapshot, readPlanItemDragIndex, + readPlanItemDragItemId, } from "@/lib/plan/planItemReorder"; import { newOrderIndexAfterMove } from "@/lib/plan/scheduleItemPlaces"; import type { PlanPlace } from "@/lib/plan/types"; @@ -80,6 +83,7 @@ export function usePlanItineraryReorder({ const mutationLocked = interactionLocked || isReorderSettling || isMovePending; + const scheduleResyncPending = useScheduleResyncPending(roomId); const isDraggingActive = dragFromIndex !== null || incomingExternalDrag; useDragAutoScroll({ active: isDraggingActive }); @@ -167,7 +171,9 @@ export function usePlanItineraryReorder({ const handleDragStart = useCallback( (index: number) => (e: DragEvent) => { const itemId = places[index]?.itemId; - if (mutationLocked || typeof itemId !== "number") return; + if (mutationLocked || scheduleResyncPending || typeof itemId !== "number") { + return; + } const target = e.currentTarget; if (!(target instanceof HTMLElement)) return; beginPlanItemDrag(e.nativeEvent, target, index, { @@ -182,7 +188,7 @@ export function usePlanItineraryReorder({ setDragFromIndex(index); setPreviewIndex(index); }, - [beginItemDrag, mutationLocked, places, scheduleId], + [beginItemDrag, mutationLocked, places, scheduleId, scheduleResyncPending], ); const handleDragEnd = useCallback(() => { @@ -260,6 +266,13 @@ export function usePlanItineraryReorder({ previewIndex ?? (Number.isFinite(fromIndex) ? fromIndex : null); + // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 + if (scheduleResyncPending) { + resetDragPreview(); + toast.error(PLAN_ORDER_RESYNCING_MESSAGE); + return; + } + const crossDayHandled = await executePlanItemCrossDayMove({ dataTransfer, roomId, @@ -290,7 +303,8 @@ export function usePlanItineraryReorder({ return; } - const itemId = places[fromIndex]?.itemId; + const itemId = + readPlanItemDragItemId(dataTransfer) ?? places[fromIndex]?.itemId; if (typeof itemId !== "number") return; try { @@ -323,6 +337,7 @@ export function usePlanItineraryReorder({ resetDragPreview, roomId, scheduleId, + scheduleResyncPending, ], ); @@ -377,7 +392,9 @@ export function usePlanItineraryReorder({ displayOrderIndex, isDragging, dragDisabled: - mutationLocked || typeof places[index]?.itemId !== "number", + mutationLocked || + scheduleResyncPending || + typeof places[index]?.itemId !== "number", onDragStart: handleDragStart(index), onDragEnd: handleDragEnd, }, @@ -392,6 +409,7 @@ export function usePlanItineraryReorder({ mutationLocked, places, previewIndex, + scheduleResyncPending, setRowRef, ], ); diff --git a/src/hooks/usePlanScheduleDayReorder.ts b/src/hooks/usePlanScheduleDayReorder.ts index f8cb348d..710971fe 100644 --- a/src/hooks/usePlanScheduleDayReorder.ts +++ b/src/hooks/usePlanScheduleDayReorder.ts @@ -10,11 +10,13 @@ import { import { toast } from "sonner"; import { useDragAutoScroll } from "@/hooks/useDragAutoScroll"; +import { useScheduleResyncPending } from "@/hooks/useScheduleResyncPending"; import { useMoveRoomSchedule } from "@/hooks/useRooms"; import type { RoomSchedule } from "@/lib/api/rooms"; import { computeRowTranslateY, getPlanItemRowReorderStyle, + PLAN_ORDER_RESYNCING_MESSAGE, PlanItemRowLayoutSnapshot, } from "@/lib/plan/planItemReorder"; import { @@ -44,6 +46,7 @@ export function usePlanScheduleDayReorder({ const pointerYRef = useRef(0); const dragOverRafRef = useRef(0); + const scheduleResyncPending = useScheduleResyncPending(roomId); const dragLocked = interactionLocked || isMovePending || schedules.length < 2; const isDraggingActive = dragFromIndex !== null; @@ -90,7 +93,7 @@ export function usePlanScheduleDayReorder({ const handleDragStart = useCallback( (index: number) => (e: DragEvent) => { - if (dragLocked || schedules[index] == null) return; + if (dragLocked || scheduleResyncPending || schedules[index] == null) return; const sectionEl = sectionRefs.current[index]; if (sectionEl instanceof HTMLElement) { beginPlanScheduleDayDrag(e.nativeEvent, sectionEl, index); @@ -99,7 +102,7 @@ export function usePlanScheduleDayReorder({ setDragFromIndex(index); setPreviewIndex(index); }, - [dragLocked, schedules], + [dragLocked, scheduleResyncPending, schedules], ); const handleDragEnd = useCallback(() => { @@ -144,6 +147,12 @@ export function usePlanScheduleDayReorder({ resetDragPreview(); + // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 + if (scheduleResyncPending) { + toast.error(PLAN_ORDER_RESYNCING_MESSAGE); + return; + } + if ( !Number.isFinite(fromIndex) || fromIndex < 0 || @@ -175,7 +184,7 @@ export function usePlanScheduleDayReorder({ ); } }, - [moveMutate, previewIndex, resetDragPreview, roomId, schedules], + [moveMutate, previewIndex, resetDragPreview, roomId, scheduleResyncPending, schedules], ); const getSectionProps = useCallback( @@ -209,7 +218,8 @@ export function usePlanScheduleDayReorder({ motionEnabled: isDraggingActive, }), dragHandleProps: { - dragDisabled: dragLocked || schedules[index] == null, + dragDisabled: + dragLocked || scheduleResyncPending || schedules[index] == null, isDragging, onDragStart: handleDragStart(index), onDragEnd: handleDragEnd, @@ -223,6 +233,7 @@ export function usePlanScheduleDayReorder({ handleDragStart, isDraggingActive, previewIndex, + scheduleResyncPending, schedules, setSectionRef, ], diff --git a/src/hooks/useRoomSchedules.test.tsx b/src/hooks/useRoomSchedules.test.tsx new file mode 100644 index 00000000..43ceeaa3 --- /dev/null +++ b/src/hooks/useRoomSchedules.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { expect, it, vi } from "vitest"; + +import { roomSchedulesQueryKey } from "@/lib/query-keys"; +import { useRoomSchedules } from "./useRooms"; + +const rooms = vi.hoisted(() => ({ hydrate: vi.fn() })); +vi.mock("@/lib/rooms", async (importOriginal) => ({ + ...(await importOriginal()), + hydrateRoomSchedulesFromServer: rooms.hydrate, +})); + +function Probe() { + useRoomSchedules("room"); + return null; +} + +it("reuses schedules on reentry but refetches them when they were invalidated while away", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + rooms.hydrate.mockResolvedValue([]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const root = createRoot(document.createElement("div")); + const render = (visible: boolean) => + root.render( + {visible && }, + ); + try { + await act(async () => render(true)); + await act(async () => render(false)); + await act(async () => render(true)); + expect(rooms.hydrate).toHaveBeenCalledTimes(1); + + await act(async () => render(false)); + await client.invalidateQueries({ queryKey: roomSchedulesQueryKey("room") }); + expect(rooms.hydrate).toHaveBeenCalledTimes(1); + + await act(async () => render(true)); + expect(rooms.hydrate).toHaveBeenCalledTimes(2); + } finally { + await act(async () => root.unmount()); + client.clear(); + vi.unstubAllGlobals(); + } +}); diff --git a/src/hooks/useRoomUnreadCount.ts b/src/hooks/useRoomUnreadCount.ts index 79872cbf..60efcd3d 100644 --- a/src/hooks/useRoomUnreadCount.ts +++ b/src/hooks/useRoomUnreadCount.ts @@ -16,7 +16,6 @@ export function useRoomUnreadCount(roomId: string | null) { /** 패널 열림 중에는 GET 없음 — STOMP read + optimistic 캐시만 사용 */ enabled: id.length > 0 && !chatPanelOpen, staleTime: Number.POSITIVE_INFINITY, - refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, }); diff --git a/src/hooks/useRooms.analytics.test.tsx b/src/hooks/useRooms.analytics.test.tsx index 52075034..4c8a0a56 100644 --- a/src/hooks/useRooms.analytics.test.tsx +++ b/src/hooks/useRooms.analytics.test.tsx @@ -11,14 +11,14 @@ import { roomDetailQueryKey, roomMembersQueryKey, sessionUserQueryKey } from "@/ import { useSessionStore } from "@/stores/session-store"; const calls = vi.hoisted(() => ({ - issue: vi.fn(), update: vi.fn(), hydrate: vi.fn(), + issue: vi.fn(), update: vi.fn(), refresh: vi.fn(), track: vi.fn(), initAll: vi.fn(async () => {}), setUserId: vi.fn(), setOptOut: vi.fn(), reset: vi.fn(), })); vi.mock("@/lib/api/rooms", async (original) => ({ ...await original(), regenerateInviteCode: calls.issue, updateRoom: calls.update, })); vi.mock("@/lib/rooms", async (original) => ({ - ...await original(), hydrateRoomSchedulesFromServer: calls.hydrate, + ...await original(), refreshRoomSchedules: calls.refresh, })); vi.mock("@amplitude/unified", () => ({ track: calls.track, initAll: calls.initAll, setUserId: calls.setUserId, setOptOut: calls.setOptOut, reset: calls.reset, @@ -57,7 +57,7 @@ beforeEach(async () => { await Promise.resolve(); await Promise.resolve(); calls.issue.mockResolvedValue({ inviteCode: "new-private-code" }); calls.update.mockResolvedValue({ ...detail, title: "changed-private-title" }); - calls.hydrate.mockResolvedValue([]); + calls.refresh.mockResolvedValue([]); await act(async () => { renderer = create(); }); }); afterEach(async () => { @@ -84,7 +84,7 @@ it("does not count automatic fallback or failed issuance", async () => { }); it("uses the actual PATCH response role and counts success before a failing follow-up refresh", async () => { client.setQueryData(roomDetailQueryKey(detail.id), { ...detail, role: "HOST" }); - calls.hydrate.mockRejectedValue(new Error("refresh failed")); + calls.refresh.mockRejectedValue(new Error("refresh failed")); await act(async () => { await expect(update.mutateAsync({ ...variables, data: { endDate: "2026-10-13" } })).rejects.toThrow("refresh failed"); }); expect(events()).toEqual([{ event_type: "room_info_updated", event_properties: { room_id: detail.id, role: "member", page_path: "/", page_location: window.location.origin + "/" }, groups: { room_id: detail.id } }]); }); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index f6668bc8..94e923b7 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -62,6 +62,7 @@ import { applyScheduleItemDeletedOnClient, createScheduleItemAtPlanIndex, ensureSchedulePlanPlacesEnriched, + setSchedulePlanPlaces, syncAfterCrossScheduleItemMove, syncPlanPlacesAfterReorderSuccess, } from "@/lib/plan/scheduleItemPlaces"; @@ -84,6 +85,7 @@ import { } from "@/lib/query-keys"; import { hydrateRoomSchedulesFromServer, + refreshRoomSchedules, syncRoomDetailFromServer, } from "@/lib/rooms"; import type { PlanPlace } from "@/lib/plan/types"; @@ -153,7 +155,7 @@ export function useCreateRoom() { }); } queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }); - await hydrateRoomSchedulesFromServer(queryClient, room.id); + await refreshRoomSchedules(queryClient, room.id); await syncRoomDetailFromServer(queryClient, room.id); }, }); @@ -164,10 +166,10 @@ export function useRoomSchedules(roomId: string | null) { const queryClient = useQueryClient(); return useQuery({ queryKey: roomSchedulesQueryKey(id || null), - queryFn: () => hydrateRoomSchedulesFromServer(queryClient, id), + queryFn: ({ signal }) => + hydrateRoomSchedulesFromServer(queryClient, id, signal), enabled: id.length > 0, staleTime: Infinity, - refetchOnMount: false, refetchOnWindowFocus: false, }); } @@ -211,7 +213,7 @@ export function useDeleteRoomSchedule() { const id = roomId.trim(); if (!id.length) return; - const sorted = await hydrateRoomSchedulesFromServer(queryClient, id); + const sorted = await refreshRoomSchedules(queryClient, id); const scheduleStillExists = sorted.some((s) => s.scheduleId === scheduleId); if (scheduleStillExists) { await invalidateScheduleItemRouteForWholeSchedule( @@ -243,7 +245,7 @@ export function useCreateRoomSchedule() { onSuccess: async (_created, { roomId }) => { const id = roomId.trim(); if (!id.length) return; - await hydrateRoomSchedulesFromServer(queryClient, id); + await refreshRoomSchedules(queryClient, id); await syncRoomDetailFromServer(queryClient, id); }, }); @@ -266,7 +268,7 @@ export function useMoveRoomSchedule() { const id = roomId.trim(); if (!id.length) return; await invalidateExpenses(queryClient, id); - await hydrateRoomSchedulesFromServer(queryClient, id); + await refreshRoomSchedules(queryClient, id); }, }); } @@ -386,7 +388,7 @@ export function useUpdateScheduleItem() { memoTouched: Object.prototype.hasOwnProperty.call(body, "memo"), }); if (merged) { - queryClient.setQueryData(key, merged); + setSchedulePlanPlaces(queryClient, rid, scheduleId, merged); } else { void queryClient.invalidateQueries({ queryKey: key, @@ -424,7 +426,7 @@ export function useUpdateScheduleItemTravelMode() { const prev = queryClient.getQueryData(key); const merged = applyRoomScheduleItemToPlanPlaces(prev, updated); if (merged) { - queryClient.setQueryData(key, merged); + setSchedulePlanPlaces(queryClient, rid, scheduleId, merged); } else { void queryClient.invalidateQueries({ queryKey: key, @@ -558,7 +560,7 @@ export function useUpdateRoom() { if (datesChanged) { await invalidateExpenses(queryClient, roomId); - await hydrateRoomSchedulesFromServer(queryClient, roomId); + await refreshRoomSchedules(queryClient, roomId); } }, }); @@ -613,7 +615,6 @@ export function useRoomMembers(roomId: string | null) { queryFn: () => getRoomMembers(roomId!), enabled: !!roomId && (!waitForStompBeforeMembers || stompConnected), staleTime: Infinity, - refetchOnMount: false, refetchOnWindowFocus: false, }); } diff --git a/src/hooks/useScheduleResyncPending.ts b/src/hooks/useScheduleResyncPending.ts new file mode 100644 index 00000000..be3433ee --- /dev/null +++ b/src/hooks/useScheduleResyncPending.ts @@ -0,0 +1,16 @@ +import { useIsFetching } from "@tanstack/react-query"; + +import { useStompContext } from "@/contexts/StompContext"; +import { roomSchedulesQueryKey } from "@/lib/query-keys"; + +/** + * 연결이 끊긴 동안이나 놓친 변경을 다시 받는 동안 — 이때 보이는 일정 순서는 서버와 다를 수 있다. + * 재정렬 API는 절대 위치만 받으므로 이 구간에는 순서를 바꾸지 않는다. + */ +export function useScheduleResyncPending(roomId: string): boolean { + const { connected } = useStompContext(); + const rid = roomId.trim(); + const isResyncing = + useIsFetching({ queryKey: roomSchedulesQueryKey(rid || null), exact: true }) > 0; + return !connected || isResyncing; +} diff --git a/src/lib/api/rooms/messages.ts b/src/lib/api/rooms/messages.ts index 27223a06..ca3802e0 100644 --- a/src/lib/api/rooms/messages.ts +++ b/src/lib/api/rooms/messages.ts @@ -20,6 +20,7 @@ export async function getRoomMessages( return requestJson(url.toString(), undefined, { errorMessage: "메시지 목록 조회 실패", + useHttpError: true, }); } @@ -31,6 +32,7 @@ export async function getRoomMessageReadStatus( undefined, { errorMessage: "읽음 위치 조회 실패", + useHttpError: true, }, ); } diff --git a/src/lib/api/rooms/schedules.ts b/src/lib/api/rooms/schedules.ts index ba50775d..67a17a61 100644 --- a/src/lib/api/rooms/schedules.ts +++ b/src/lib/api/rooms/schedules.ts @@ -36,6 +36,7 @@ export type RoomScheduleWithItems = RoomSchedule & { export type GetRoomSchedulesOptions = { includeItems?: boolean; + signal?: AbortSignal; }; export async function getRoomSchedules( @@ -46,7 +47,7 @@ export async function getRoomSchedules( if (options?.includeItems) { url.searchParams.set("includeItems", "true"); } - return requestJson(url.toString(), undefined, { + return requestJson(url.toString(), { signal: options?.signal }, { errorMessage: "일정 목록 조회 실패", }); } diff --git a/src/lib/auth-server.test.ts b/src/lib/auth-server.test.ts new file mode 100644 index 00000000..8d358c57 --- /dev/null +++ b/src/lib/auth-server.test.ts @@ -0,0 +1,171 @@ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.hoisted(() => { + process.env.API_BASE_URL = "http://backend.test"; +}); + +import { + sessionCheckResponse, + verifySessionWithOptionalRefresh, +} from "@/lib/auth-server"; + +const COOKIE = "access_token=old-access; refresh_token=old-refresh"; +const REFRESHED_COOKIES = [ + "access_token=new-access; Path=/; HttpOnly", + "refresh_token=new-refresh; Path=/; HttpOnly", +]; + +const fetchMock = vi.fn(); + +function status(code: number): Response { + return new Response(null, { status: code }); +} + +function refreshed(): Response { + const headers = new Headers(); + for (const cookie of REFRESHED_COOKIES) headers.append("set-cookie", cookie); + return new Response(null, { status: 200, headers }); +} + +function sessionRequest(cookie: string): NextRequest { + return new NextRequest("https://www.uttae.app/api/auth/session", { + headers: { cookie }, + }); +} + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("verifySessionWithOptionalRefresh", () => { + it("treats a missing cookie as unauthenticated without calling the backend", async () => { + await expect(verifySessionWithOptionalRefresh(null)).resolves.toEqual({ + ok: false, + unavailable: false, + setCookies: [], + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("marks an unreachable backend as unavailable", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("fetch failed")); + + await expect(verifySessionWithOptionalRefresh(COOKIE)).resolves.toEqual({ + ok: false, + unavailable: true, + setCookies: [], + }); + }); + + it.each([500, 502, 503, 429])( + "marks users/me %i as unavailable without refreshing", + async (code) => { + fetchMock.mockResolvedValueOnce(status(code)); + + const result = await verifySessionWithOptionalRefresh(COOKIE); + + expect(result).toMatchObject({ ok: false, unavailable: true }); + expect(fetchMock).toHaveBeenCalledOnce(); + }, + ); + + it("treats an expired access token without refresh_token as unauthenticated", async () => { + fetchMock.mockResolvedValueOnce(status(401)); + + await expect( + verifySessionWithOptionalRefresh("access_token=old-access"), + ).resolves.toEqual({ ok: false, unavailable: false, setCookies: [] }); + }); + + it("treats a rejected refresh as unauthenticated", async () => { + fetchMock.mockResolvedValueOnce(status(401)).mockResolvedValueOnce(status(401)); + + await expect(verifySessionWithOptionalRefresh(COOKIE)).resolves.toEqual({ + ok: false, + unavailable: false, + setCookies: [], + }); + }); + + it("marks a failed refresh request as unavailable", async () => { + fetchMock.mockResolvedValueOnce(status(401)).mockResolvedValueOnce(status(502)); + + await expect(verifySessionWithOptionalRefresh(COOKIE)).resolves.toEqual({ + ok: false, + unavailable: true, + setCookies: [], + }); + }); + + it("returns refreshed cookies after a successful refresh", async () => { + fetchMock + .mockResolvedValueOnce(status(401)) + .mockResolvedValueOnce(refreshed()) + .mockResolvedValueOnce(status(200)); + + await expect(verifySessionWithOptionalRefresh(COOKIE)).resolves.toEqual({ + ok: true, + unavailable: false, + setCookies: REFRESHED_COOKIES, + }); + }); + + it("keeps refreshed cookies when the backend fails right after refreshing", async () => { + fetchMock + .mockResolvedValueOnce(status(401)) + .mockResolvedValueOnce(refreshed()) + .mockResolvedValueOnce(status(503)); + + await expect(verifySessionWithOptionalRefresh(COOKIE)).resolves.toEqual({ + ok: false, + unavailable: true, + setCookies: REFRESHED_COOKIES, + }); + }); +}); + +describe("sessionCheckResponse", () => { + it("responds 200 for a valid session", async () => { + fetchMock.mockResolvedValueOnce(status(200)); + + const response = await sessionCheckResponse(sessionRequest(COOKIE)); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("responds 503 when the backend is unavailable so clients retry instead of logging out", async () => { + fetchMock.mockResolvedValueOnce(status(502)); + + const response = await sessionCheckResponse(sessionRequest(COOKIE)); + + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("forwards refreshed cookies with 503", async () => { + fetchMock + .mockResolvedValueOnce(status(401)) + .mockResolvedValueOnce(refreshed()) + .mockResolvedValueOnce(status(503)); + + const response = await sessionCheckResponse(sessionRequest(COOKIE)); + + expect(response.status).toBe(503); + expect(response.headers.getSetCookie()).toEqual(REFRESHED_COOKIES); + }); + + it("responds 401 only when authentication is confirmed to have failed", async () => { + fetchMock.mockResolvedValueOnce(status(401)).mockResolvedValueOnce(status(401)); + + const response = await sessionCheckResponse(sessionRequest(COOKIE)); + + expect(response.status).toBe(401); + }); +}); diff --git a/src/lib/auth-server.ts b/src/lib/auth-server.ts index 46f3c925..e1e44484 100644 --- a/src/lib/auth-server.ts +++ b/src/lib/auth-server.ts @@ -40,10 +40,25 @@ function cookieHeaderHasRefreshToken(cookieHeader: string): boolean { type SessionVerifyResult = { ok: boolean; + /** `ok`가 false인 이유가 백엔드 장애(도달 실패·5xx·429)인지. 인증 실패 확정과 구분한다 */ + unavailable: boolean; /** refresh 성공 시 브라우저에 전달할 Set-Cookie */ setCookies: string[]; }; +function unauthenticatedResult(): SessionVerifyResult { + return { ok: false, unavailable: false, setCookies: [] }; +} + +function unavailableResult(setCookies: string[] = []): SessionVerifyResult { + return { ok: false, unavailable: true, setCookies }; +} + +/** 백엔드가 응답했지만 지금 처리하지 못하는 상태 — 인증 실패로 보지 않는다 */ +function isBackendUnavailable(res: Response): boolean { + return res.status >= 500 || res.status === 429; +} + /** * 백엔드 도달 불가(네트워크 오류 등)와 정상 응답을 구분하기 위한 래퍼. * 도달 실패 시 `null`을 반환하고, 예외를 호출부로 전파하지 않습니다. @@ -82,7 +97,7 @@ async function fetchBackendRefresh( function warnSessionFetchFailed(label: string): void { if (process.env.NODE_ENV === "development") { - console.warn(`[auth-server] ${label} unreachable — treating as unauthenticated`); + console.warn(`[auth-server] ${label} unreachable`); } } @@ -90,7 +105,7 @@ function warnSessionFetchFailed(label: string): void { * `users/me`로 세션 검증. access 만료(401 등)이고 refresh_token이 있으면 * `POST /auth/refresh` 후 `users/me` 재시도. * - * 백엔드 도달 불가(네트워크 오류)는 fail-closed(`ok: false`)로 처리합니다. + * 백엔드 도달 불가·5xx·429는 `unavailable`로 표시하고 `ok: false`(fail-closed)로 둡니다. * 예외를 던지지 않아 미들웨어 크래시·dev 자동 리로드를 막고, * fail-open 시 생기던 `/home`↔`/login` document 루프도 차단합니다. */ @@ -99,52 +114,61 @@ export async function verifySessionWithOptionalRefresh( ): Promise { const cookie = cookieHeader?.trim() ?? ""; if (!cookie.length) { - return { ok: false, setCookies: [] }; + return unauthenticatedResult(); } const meRes = await fetchUsersMe(cookie); if (meRes === null) { warnSessionFetchFailed("GET /users/me"); - return { ok: false, setCookies: [] }; + return unavailableResult(); } if (meRes.ok) { - return { ok: true, setCookies: [] }; + return { ok: true, unavailable: false, setCookies: [] }; + } + if (isBackendUnavailable(meRes)) { + return unavailableResult(); } if (!cookieHeaderHasRefreshToken(cookie)) { - return { ok: false, setCookies: [] }; + return unauthenticatedResult(); } const refreshRes = await fetchBackendRefresh(cookie); if (refreshRes === null) { warnSessionFetchFailed("POST /auth/refresh"); - return { ok: false, setCookies: [] }; + return unavailableResult(); + } + if (isBackendUnavailable(refreshRes)) { + return unavailableResult(); } if (!refreshRes.ok) { - return { ok: false, setCookies: [] }; + return unauthenticatedResult(); } const setCookies = getSetCookieHeaders(refreshRes); const retryCookie = mergeSetCookiesIntoCookieHeader(cookie, setCookies); const retryMe = await fetchUsersMe(retryCookie); + // refresh가 성공했으면 이전 refresh_token은 이미 폐기됐다. 재확인이 장애로 실패해도 + // 새 쿠키를 전달해야 다음 확인에서 폐기된 토큰을 다시 쓰지 않는다 if (retryMe === null) { warnSessionFetchFailed("GET /users/me (after refresh)"); - return { ok: false, setCookies: [] }; + return unavailableResult(setCookies); } - - return { - ok: retryMe.ok, - setCookies: retryMe.ok ? setCookies : [], - }; + if (retryMe.ok) { + return { ok: true, unavailable: false, setCookies }; + } + if (isBackendUnavailable(retryMe)) { + return unavailableResult(setCookies); + } + return unauthenticatedResult(); } /** `GET /api/auth/session` Route Handler 본문 */ export async function sessionCheckResponse( request: NextRequest, ): Promise { - const { ok, setCookies } = await verifySessionWithOptionalRefresh( - request.headers.get("cookie"), - ); + const { ok, unavailable, setCookies } = + await verifySessionWithOptionalRefresh(request.headers.get("cookie")); const baseHeaders = { "Cache-Control": "no-store" } as const; @@ -157,6 +181,16 @@ export async function sessionCheckResponse( return res; } + // 백엔드 장애는 로그아웃 사유가 아니다. 클라이언트는 503을 일시 오류로 보고 다시 확인한다 + if (unavailable) { + const res = NextResponse.json( + { authenticated: false }, + { status: 503, headers: baseHeaders }, + ); + appendSetCookies(res, setCookies); + return res; + } + return NextResponse.json( { authenticated: false }, { status: 401, headers: baseHeaders }, diff --git a/src/lib/chat.test.ts b/src/lib/chat.test.ts new file mode 100644 index 00000000..21862df5 --- /dev/null +++ b/src/lib/chat.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +import type { ServerChatMessage } from "@/types/chat"; + +import { + mergeServerMessageLists, + partitionLiveMessages, + replaceExistingServerMessages, + tailCursorOf, +} from "./chat"; + +function msg(sequence: number | undefined, id = `m${sequence}`): ServerChatMessage { + return { + id, + roomId: "room", + senderId: 2, + messageType: "CHAT", + content: id, + createdAt: new Date(Date.UTC(2026, 9, 25, 0, 0, sequence ?? 0)).toISOString(), + ...(sequence != null ? { sequence } : {}), + }; +} + +function aiRequest(aiStatus: string): ServerChatMessage { + return { + ...msg(50, "ai-50"), + messageType: "AI_REQUEST", + metadata: { aiStatus, cancelable: aiStatus === "QUEUED" ? "true" : "false" }, + }; +} + +const ids = (list: readonly ServerChatMessage[]) => list.map((m) => m.id); + +describe("partitionLiveMessages", () => { + const tail = { atTail: true, list: [msg(39), msg(40)] }; + + it("appends only the next sequence and advances the cursor", () => { + const result = partitionLiveMessages([msg(41), msg(42)], { ...tail, cursor: 40 }); + expect(ids(result.appended)).toEqual(["m41", "m42"]); + expect(result.cursor).toBe(42); + expect(result.hasGap).toBe(false); + }); + + it("reports a gap instead of appending past a missing sequence", () => { + const result = partitionLiveMessages([msg(41), msg(43)], { ...tail, cursor: 40 }); + expect(ids(result.appended)).toEqual(["m41"]); + expect(result.cursor).toBe(41); + expect(result.hasGap).toBe(true); + }); + + it("treats sequences up to the cursor as updates of received messages", () => { + const result = partitionLiveMessages([msg(40)], { ...tail, cursor: 40 }); + expect(result.appended).toEqual([]); + expect(ids(result.updates)).toEqual(["m40"]); + }); + + it("sorts a buffered batch so out-of-order delivery is not a gap", () => { + const result = partitionLiveMessages([msg(42), msg(41)], { ...tail, cursor: 40 }); + expect(ids(result.appended)).toEqual(["m41", "m42"]); + expect(result.hasGap).toBe(false); + }); + + it("does not append in a middle window", () => { + const result = partitionLiveMessages([msg(41)], { ...tail, atTail: false, cursor: undefined }); + expect(result.appended).toEqual([]); + expect(ids(result.updates)).toEqual(["m41"]); + }); + + it("starts an empty room at sequence 1", () => { + const result = partitionLiveMessages([msg(1)], { atTail: true, list: [], cursor: 0 }); + expect(ids(result.appended)).toEqual(["m1"]); + expect(result.cursor).toBe(1); + }); + + it("falls back to ids for messages without a sequence", () => { + const legacy = msg(undefined, "legacy"); + const result = partitionLiveMessages([legacy, msg(undefined, "new")], { + atTail: true, + list: [legacy], + cursor: undefined, + }); + expect(ids(result.updates)).toEqual(["legacy"]); + expect(ids(result.appended)).toEqual(["new"]); + }); +}); + +describe("mergeServerMessageLists", () => { + it("keeps the written order when sequences were assigned out of order", () => { + // 거의 동시에 저장되면 먼저 쓴 메시지가 더 큰 sequence를 받을 수 있다 + const writtenFirst = { ...msg(42), createdAt: "2026-10-25T00:00:00.118Z" }; + const writtenLater = { ...msg(41), createdAt: "2026-10-25T00:00:00.139Z" }; + expect(ids(mergeServerMessageLists([writtenLater], [writtenFirst]))).toEqual([ + "m42", + "m41", + ]); + }); + + it("keeps the later copy of the same message", () => { + const edited = { ...msg(41), content: "edited" }; + expect(mergeServerMessageLists([msg(41)], [edited])[0]?.content).toBe("edited"); + }); + + it("does not put a processed or canceled AI request back into the queue", () => { + const [kept] = mergeServerMessageLists([aiRequest("CANCELED")], [aiRequest("QUEUED")]); + expect(kept?.metadata?.aiStatus).toBe("CANCELED"); + }); + + it("moves an AI request forward from the queue", () => { + const [kept] = mergeServerMessageLists([aiRequest("QUEUED")], [aiRequest("PROCESSING")]); + expect(kept?.metadata?.aiStatus).toBe("PROCESSING"); + }); +}); + +describe("replaceExistingServerMessages", () => { + it("replaces listed ids and ignores new ones", () => { + const list = [msg(40), aiRequest("QUEUED")]; + const next = replaceExistingServerMessages(list, [aiRequest("CANCELED"), msg(41)]); + expect(ids(next)).toEqual(["m40", "ai-50"]); + expect(next[1]?.metadata?.aiStatus).toBe("CANCELED"); + }); + + it("keeps the same list when nothing changes", () => { + const list = [msg(40)]; + expect(replaceExistingServerMessages(list, [msg(41)])).toBe(list); + }); +}); + +describe("tailCursorOf", () => { + it("uses 0 for an empty room so the first message is next", () => { + expect(tailCursorOf([])).toBe(0); + }); + + it("uses the largest sequence of the snapshot", () => { + expect(tailCursorOf([msg(39), msg(40)])).toBe(40); + }); + + it("has no cursor when no message carries a sequence", () => { + expect(tailCursorOf([msg(undefined, "legacy")])).toBeUndefined(); + }); +}); diff --git a/src/lib/chat.ts b/src/lib/chat.ts index de8654d6..673569e2 100644 --- a/src/lib/chat.ts +++ b/src/lib/chat.ts @@ -589,16 +589,40 @@ function formatQuotePreview(content: string, maxLen = 96): string { return `${oneLine.slice(0, maxLen).trimEnd()}…`; } +/** AI 요청 상태는 대기(QUEUED)에서 처리(PROCESSING)나 취소(CANCELED)로만 바뀐다 */ +function aiRequestStatusRank(msg: ServerChatMessage): number | undefined { + if (normalizeMessageKind(msg.messageType) !== "AI_REQUEST") return undefined; + const status = normalizeAiStatus(msg.metadata?.aiStatus ?? msg.metadata?.aistatus); + if (status === undefined) return undefined; + return status === "QUEUED" ? 0 : 1; +} + +/** + * 같은 id는 나중에 받은 것(b)을 쓴다. AI 요청만은 대기 상태로 되돌리지 않는다 — + * 상태가 바뀌기 전에 서버가 만든 조회 응답이 실시간으로 받은 처리·취소 상태를 덮지 않게. + */ function preferNewerServerMessage( a: ServerChatMessage, b: ServerChatMessage, ): ServerChatMessage { - if (!a.createdAt) return b; - if (!b.createdAt) return a; - return b.createdAt >= a.createdAt ? b : a; + const before = aiRequestStatusRank(a); + const after = aiRequestStatusRank(b); + return before !== undefined && after !== undefined && after < before ? a : b; } -/** 같은 id 는 하나만 유지(타임스탬프가 더 최신인 행). */ +/** 커서 판단용 방별 sequence 순서. sequence가 없는 옛 메시지는 작성 시각으로 비교한다 */ +function compareBySequence( + x: ServerChatMessage, + y: ServerChatMessage, +): number { + if (x.sequence != null && y.sequence != null) return x.sequence - y.sequence; + return x.createdAt.localeCompare(y.createdAt); +} + +/** + * 같은 id는 하나만 유지하고 작성 시각 순으로 정렬한다. + * 거의 동시에 저장된 메시지는 서버가 충돌을 재시도한 순서로 sequence를 받아 보낸 순서와 다를 수 있다. + */ export function mergeServerMessageLists( ...lists: ServerChatMessage[][] ): ServerChatMessage[] { @@ -616,6 +640,93 @@ export function mergeServerMessageLists( return [...map.values()].sort((x, y) => x.createdAt.localeCompare(y.createdAt)); } +/** 목록에 이미 있는 id만 새 내용으로 바꾼다. 새 id는 붙이지 않는다 */ +export function replaceExistingServerMessages( + list: ServerChatMessage[], + updates: readonly ServerChatMessage[], +): ServerChatMessage[] { + if (updates.length === 0) return list; + const byId = new Map(); + for (const m of updates) { + const prev = byId.get(m.id); + byId.set(m.id, prev ? preferNewerServerMessage(prev, m) : m); + } + let changed = false; + const next = list.map((m) => { + const update = byId.get(m.id); + if (!update) return m; + const kept = preferNewerServerMessage(m, update); + if (kept !== m) changed = true; + return kept; + }); + return changed ? next : list; +} + +export type LiveMessagePartition = { + /** 커서 다음 번호부터 이어지는 새 메시지 — 목록 끝에 붙인다 */ + appended: ServerChatMessage[]; + /** 이미 반영한 번호거나 중간 창에서 받은 메시지 — 목록에 있는 id만 바꾼다 */ + updates: ServerChatMessage[]; + cursor: number | undefined; + /** 커서 다음 번호가 비었다 — 서버에서 따라잡아야 한다 */ + hasGap: boolean; +}; + +/** + * 실시간 메시지를 sequence 커서로 나눈다. 커서는 목록 끝까지 빠짐없이 반영한 마지막 번호다. + * - 목록이 최신 끝이 아니면(중간 창) 새 메시지를 붙이지 않는다. 붙이면 그 사이가 빈다 + * - 커서+1만 붙이고, 그보다 큰 번호는 빈 번호를 따라잡을 때 함께 받는다 + * - sequence가 없는 옛 메시지는 id로만 판단한다 + */ +export function partitionLiveMessages( + msgs: readonly ServerChatMessage[], + { + cursor, + atTail, + list, + }: { + cursor: number | undefined; + atTail: boolean; + list: readonly ServerChatMessage[]; + }, +): LiveMessagePartition { + const appended: ServerChatMessage[] = []; + const updates: ServerChatMessage[] = []; + let next = cursor; + let hasGap = false; + for (const msg of [...msgs].sort(compareBySequence)) { + const seq = msg.sequence; + if (!atTail) { + updates.push(msg); + } else if (seq == null || next == null) { + if (list.some((m) => m.id === msg.id)) { + updates.push(msg); + } else { + appended.push(msg); + if (seq != null) next = seq; + } + } else if (seq <= next) { + updates.push(msg); + } else if (seq === next + 1) { + appended.push(msg); + next = seq; + } else { + hasGap = true; + } + } + return { appended, updates, cursor: next, hasGap }; +} + +/** + * 최신 끝까지 받은 스냅샷의 커서. 빈 방은 0(첫 메시지가 1번)이고, + * sequence가 없는 옛 메시지뿐이면 커서를 둘 수 없다. + */ +export function tailCursorOf( + snapshot: readonly ServerChatMessage[], +): number | undefined { + return snapshot.length === 0 ? 0 : maxSequenceInMessages(snapshot); +} + /** GET /messages (페이징) 응답 행 → 토픽과 동일한 `ServerChatMessage[]` */ export function normalizeFetchedRoomMessages( rows: readonly unknown[], @@ -663,19 +774,6 @@ export function maxSequenceInMessages( return max; } -/** - * 초기 히스토리 GET 동안 패널에서만 잠깐 쌓이는 메시지(STOMP 등) — - * 병합 시 서버 페이지와 함께 유지. - */ -export function transientMessagesDuringRoomHistoryFetch( - prev: ServerChatMessage[], -): ServerChatMessage[] { - return prev.filter((m) => { - const k = normalizeMessageKind(m.messageType); - return k === "SYSTEM" || k === "PLACE_SHARE" || k === "AI_REQUEST"; - }); -} - function buildAiRequestReplyLookup( rawMessages: ServerChatMessage[], ): Map { diff --git a/src/lib/chat/messageCatchUp.test.ts b/src/lib/chat/messageCatchUp.test.ts new file mode 100644 index 00000000..4bbc971b --- /dev/null +++ b/src/lib/chat/messageCatchUp.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { HttpError } from "@/lib/api/errors"; + +import { + CHAT_CATCH_UP_PAGE_SIZE, + fetchRoomMessagesAfter, + retryChatHistoryRequest, +} from "./messageCatchUp"; + +const mocks = vi.hoisted(() => ({ getRoomMessages: vi.fn() })); + +vi.mock("@/lib/api/rooms", () => ({ getRoomMessages: mocks.getRoomMessages })); + +function rows(from: number, to: number) { + return Array.from({ length: to - from + 1 }, (_, i) => ({ + id: `m${from + i}`, + roomId: "room", + senderId: 2, + messageType: "CHAT", + content: "", + createdAt: new Date(Date.UTC(2026, 9, 25, 0, 0, from + i)).toISOString(), + sequence: from + i, + })); +} + +const active = () => true; + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("fetchRoomMessagesAfter", () => { + it("pages past the cursor until a short page", async () => { + mocks.getRoomMessages + .mockResolvedValueOnce(rows(41, 240)) + .mockResolvedValueOnce(rows(241, 440)) + .mockResolvedValueOnce(rows(441, 450)); + + const result = await fetchRoomMessagesAfter("room", 40, active); + + expect(result).toHaveLength(410); + expect(mocks.getRoomMessages.mock.calls).toEqual([ + ["room", { afterSequence: "40", size: CHAT_CATCH_UP_PAGE_SIZE }], + ["room", { afterSequence: "240", size: CHAT_CATCH_UP_PAGE_SIZE }], + ["room", { afterSequence: "440", size: CHAT_CATCH_UP_PAGE_SIZE }], + ]); + }); + + it("asks once when nothing was missed", async () => { + mocks.getRoomMessages.mockResolvedValueOnce([]); + + await expect(fetchRoomMessagesAfter("room", 40, active)).resolves.toEqual([]); + expect(mocks.getRoomMessages).toHaveBeenCalledTimes(1); + }); + + it.each([0, undefined])( + "loads the latest page instead of afterSequence=%s", + async (cursor) => { + mocks.getRoomMessages.mockResolvedValueOnce(rows(1, 3)); + + const result = await fetchRoomMessagesAfter("room", cursor, active); + + expect(result.map((m) => m.sequence)).toEqual([1, 2, 3]); + expect(mocks.getRoomMessages).toHaveBeenCalledWith("room", { size: 15 }); + }, + ); +}); + +describe("retryChatHistoryRequest", () => { + it.each([new TypeError("Failed to fetch"), new HttpError(503, "unavailable")])( + "retries %s after the backoff delay", + async (error) => { + vi.useFakeTimers(); + const request = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce("ok"); + + const pending = retryChatHistoryRequest(request, active); + await vi.advanceTimersByTimeAsync(999); + expect(request).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + + await expect(pending).resolves.toBe("ok"); + expect(request).toHaveBeenCalledTimes(2); + }, + ); + + it.each([400, 403, 404])("does not retry %s", async (status) => { + const request = vi.fn().mockRejectedValue(new HttpError(status, "rejected")); + + await expect(retryChatHistoryRequest(request, active)).rejects.toBeInstanceOf(HttpError); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("gives up after three retries", async () => { + vi.useFakeTimers(); + const request = vi.fn().mockRejectedValue(new HttpError(500, "down")); + + const pending = retryChatHistoryRequest(request, active); + const settled = expect(pending).rejects.toBeInstanceOf(HttpError); + await vi.advanceTimersByTimeAsync(1_000 + 2_000 + 4_000); + + await settled; + expect(request).toHaveBeenCalledTimes(4); + }); + + it("stops when the room is left during the wait", async () => { + vi.useFakeTimers(); + let isActive = true; + const request = vi.fn().mockRejectedValue(new HttpError(500, "down")); + + const pending = retryChatHistoryRequest(request, () => isActive); + const settled = expect(pending).rejects.toBeInstanceOf(HttpError); + isActive = false; + await vi.advanceTimersByTimeAsync(1_000); + + await settled; + expect(request).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/chat/messageCatchUp.ts b/src/lib/chat/messageCatchUp.ts new file mode 100644 index 00000000..ede6500c --- /dev/null +++ b/src/lib/chat/messageCatchUp.ts @@ -0,0 +1,87 @@ +import type { ServerChatMessage } from "@/types/chat"; +import { getRoomMessages } from "@/lib/api/rooms"; +import { HttpError } from "@/lib/api/errors"; +import { maxSequenceInMessages, normalizeFetchedRoomMessages } from "@/lib/chat"; +import { CHAT_MESSAGE_PAGE_SIZE } from "@/lib/chat/readBoundary"; + +/** 백엔드 GET /messages `size` 상한 */ +export const CHAT_CATCH_UP_PAGE_SIZE = 200; + +/** TanStack Query 기본 재시도(3회, 1·2·4초)와 같다 */ +const CHAT_HISTORY_RETRY_DELAYS_MS = [1_000, 2_000, 4_000]; + +/** 네트워크 오류·5xx·429만 다시 보낸다. 400·403·404는 다시 보내도 같다 */ +function isRetryableHistoryError(error: unknown): boolean { + return ( + !(error instanceof HttpError) || + error.status >= 500 || + error.status === 429 + ); +} + +/** + * 채팅 기록 조회를 실패 종류에 따라 다시 보낸다. + * `isActive`가 거짓이면(방을 떠났거나 더 새 조회가 시작됨) 기다린 뒤 보내지 않는다. + */ +export async function retryChatHistoryRequest( + request: () => Promise, + isActive: () => boolean, +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await request(); + } catch (error) { + const delay = CHAT_HISTORY_RETRY_DELAYS_MS[attempt]; + if (delay === undefined || !isRetryableHistoryError(error) || !isActive()) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, delay)); + if (!isActive()) throw error; + } + } +} + +/** + * 커서 뒤의 메시지를 끝까지 받는다. + * 커서가 없거나 0이면(빈 방·sequence 없는 옛 목록) 최신 페이지를 받는다 — + * 백엔드는 afterSequence가 1보다 작으면 400을 준다. + */ +export async function fetchRoomMessagesAfter( + roomId: string, + cursor: number | undefined, + isActive: () => boolean, +): Promise { + if (cursor == null || cursor < 1) { + const latest = await retryChatHistoryRequest( + () => getRoomMessages(roomId, { size: CHAT_MESSAGE_PAGE_SIZE }), + isActive, + ); + return normalizeFetchedRoomMessages(latest); + } + + const rows: ServerChatMessage[] = []; + let after = cursor; + for (;;) { + const page = normalizeFetchedRoomMessages( + await retryChatHistoryRequest( + () => + getRoomMessages(roomId, { + afterSequence: String(after), + size: CHAT_CATCH_UP_PAGE_SIZE, + }), + isActive, + ), + ); + rows.push(...page); + const last = maxSequenceInMessages(page); + if ( + page.length < CHAT_CATCH_UP_PAGE_SIZE || + last == null || + last <= after || + !isActive() + ) { + return rows; + } + after = last; + } +} diff --git a/src/lib/plan/planItemReorder.ts b/src/lib/plan/planItemReorder.ts index 761bdeec..1b185ea4 100644 --- a/src/lib/plan/planItemReorder.ts +++ b/src/lib/plan/planItemReorder.ts @@ -10,6 +10,10 @@ export const PLAN_ITEM_DND_ITEM_ID_MIME = "application/x-plan-item-id"; export const PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR = "data-plan-itinerary-drop-zone"; +/** 연결이 끊긴 동안이나 놓친 변경을 다시 받는 동안 놓은 경우 */ +export const PLAN_ORDER_RESYNCING_MESSAGE = + "일정을 다시 맞추는 중이라 옮기지 않았어요. 잠시 후 다시 옮겨 주세요."; + export const PLAN_ITEM_REORDER_ROW_TRANSITION = "transform 200ms cubic-bezier(0.2, 0, 0, 1)"; diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index fd7e85fa..d51c2aee 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -19,6 +19,13 @@ import { } from "@/lib/places/place-queries"; import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage"; import { sortRoomSchedules } from "@/lib/plan/scheduleMerge"; +import { + bumpPlanMapRouteSegments, + invalidateScheduleItemRouteForSources, + orderedItemIdsFromPlanPlaces, + readOrderedItemIdsFromScheduleItemsCache, + segmentSourcesWithChangedNext, +} from "@/lib/plan/scheduleStompRouteScope"; import { SCHEDULE_TRAVEL_MODE_DEFAULT, canonicalScheduleTravelMode, @@ -189,32 +196,72 @@ function collectGooglePlaceIdsFromSchedules( return [...ids]; } -export async function hydrateScheduleItemsFromSchedulesWithItems( +/** + * 일차별 장소 목록을 만들기만 합니다. 미리보기를 기다리는 동안 다른 변경이 들어올 수 있어 + * 캐시 쓰기(`writeSchedulePlacesFromHydrate`)와 나눕니다. + */ +export async function buildSchedulePlacesFromSchedulesWithItems( queryClient: QueryClient, - roomId: string, schedules: readonly RoomScheduleWithItems[], -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - +): Promise> { const placeIds = collectGooglePlaceIdsFromSchedules(schedules); if (placeIds.length) { await fetchAndSeedPlacePreviews(placeIds, queryClient); } + const placesBySchedule = new Map(); for (const schedule of schedules) { const sid = schedule.scheduleId; if (typeof sid !== "number" || !Number.isFinite(sid)) continue; const items = Array.isArray(schedule.items) ? schedule.items : []; - const places = await buildPlanPlacesFromItemsWithPreviewEnrichment( - queryClient, - items, + placesBySchedule.set( + sid, + await buildPlanPlacesFromItemsWithPreviewEnrichment(queryClient, items), ); + } + return placesBySchedule; +} + +/** 만든 일차 목록을 한 번에 씁니다. 순서가 바뀐 일차는 이웃이 바뀐 구간 경로만 무효화합니다. */ +export function writeSchedulePlacesFromHydrate( + queryClient: QueryClient, + roomId: string, + placesBySchedule: ReadonlyMap, +): void { + const rid = roomId.trim(); + if (!rid.length) return; + + for (const [sid, places] of placesBySchedule) { + const before = readOrderedItemIdsFromScheduleItemsCache(queryClient, rid, sid); queryClient.setQueryData(scheduleItemsQueryKey(rid, sid), places); + + const changed = segmentSourcesWithChangedNext( + before, + orderedItemIdsFromPlanPlaces(places), + ); + if (changed.length) { + void invalidateScheduleItemRouteForSources(queryClient, rid, sid, changed); + bumpPlanMapRouteSegments(rid, sid, changed); + } } } +export async function hydrateScheduleItemsFromSchedulesWithItems( + queryClient: QueryClient, + roomId: string, + schedules: readonly RoomScheduleWithItems[], +): Promise { + const rid = roomId.trim(); + if (!rid.length) return; + + writeSchedulePlacesFromHydrate( + queryClient, + rid, + await buildSchedulePlacesFromSchedulesWithItems(queryClient, schedules), + ); +} + function batchRouteItemToResponse( item: Extract, ): import("@/lib/api/rooms/schedule-items").ScheduleItemRouteResponse { diff --git a/src/lib/plan/scheduleItemPlaces.test.ts b/src/lib/plan/scheduleItemPlaces.test.ts index aff46a07..c2bf4cd8 100644 --- a/src/lib/plan/scheduleItemPlaces.test.ts +++ b/src/lib/plan/scheduleItemPlaces.test.ts @@ -1,3 +1,4 @@ +import { QueryClient } from "@tanstack/react-query"; import { beforeAll, describe, expect, it } from "vitest"; import type { @@ -183,3 +184,45 @@ describe("end time cache and reorder", () => { ]); }); }); + +describe("setSchedulePlanPlaces", () => { + it("restarts an in-flight whole-room refetch so its older snapshot cannot overwrite the new day list", async () => { + const { setSchedulePlanPlaces } = await import("@/lib/plan/scheduleItemPlaces"); + const { roomSchedulesQueryKey, scheduleItemsQueryKey } = await import("@/lib/query-keys"); + const client = new QueryClient(); + const roomKey = roomSchedulesQueryKey("room-1"); + client.setQueryData(roomKey, []); + let fetches = 0; + const queryFn = () => { + fetches += 1; + return new Promise(() => {}); + }; + void client.fetchQuery({ queryKey: roomKey, queryFn, staleTime: 0 }).catch(() => null); + expect(fetches).toBe(1); + + setSchedulePlanPlaces(client, "room-1", 10, [planPlace(1)]); + + expect(fetches).toBe(2); + expect(client.getQueryData(scheduleItemsQueryKey("room-1", 10))).toEqual([planPlace(1)]); + client.clear(); + }); + + it("does not refetch the whole room when nothing is in flight", async () => { + const { setSchedulePlanPlaces } = await import("@/lib/plan/scheduleItemPlaces"); + const { roomSchedulesQueryKey } = await import("@/lib/query-keys"); + const client = new QueryClient(); + let fetches = 0; + client.setQueryDefaults(roomSchedulesQueryKey("room-1"), { + queryFn: () => { + fetches += 1; + return []; + }, + }); + client.setQueryData(roomSchedulesQueryKey("room-1"), []); + + setSchedulePlanPlaces(client, "room-1", 10, [planPlace(1)]); + + expect(fetches).toBe(0); + client.clear(); + }); +}); diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 76d6ad4e..264a5faf 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -27,7 +27,7 @@ import { readOrderedItemIdsFromScheduleItemsCache, removeRouteQueriesForDeletedItemSource, } from "@/lib/plan/scheduleStompRouteScope"; -import { scheduleItemsQueryKey } from "@/lib/query-keys"; +import { roomSchedulesQueryKey, scheduleItemsQueryKey } from "@/lib/query-keys"; import { addMinutesToHm, computeDurationMinutesFromRange, @@ -176,6 +176,25 @@ async function buildPlanPlacesFromScheduleItems( return Promise.all(sorted.map((item) => planPlaceFromScheduleItem(item))); } +/** + * 일차 장소 목록을 씁니다. 그사이 일정 전체 재조회가 진행 중이면 그 응답은 이 변경 전 스냅샷이라 + * 끝나는 대로 방금 쓴 일차를 되돌립니다. 진행 중인 조회를 취소하고 다시 받게 합니다. + */ +export function setSchedulePlanPlaces( + queryClient: QueryClient, + roomId: string, + scheduleId: number, + places: PlanPlace[], +): void { + const rid = roomId.trim(); + queryClient.setQueryData(scheduleItemsQueryKey(rid, scheduleId), places); + + const roomSchedulesKey = roomSchedulesQueryKey(rid); + if (queryClient.isFetching({ queryKey: roomSchedulesKey, exact: true }) > 0) { + void queryClient.refetchQueries({ queryKey: roomSchedulesKey, exact: true }); + } +} + /** DELETE 직후 `schedule-items`에서 항목만 제거(Preview·Photo 재조회 없음). */ export function removeScheduleItemFromPlanPlacesCache( queryClient: QueryClient, @@ -190,7 +209,7 @@ export function removeScheduleItemFromPlanPlacesCache( if (!prev?.length) return false; const next = prev.filter((p) => p.itemId !== deletedItemId); if (next.length === prev.length) return false; - queryClient.setQueryData(key, next); + setSchedulePlanPlaces(queryClient, rid, scheduleId, next); return true; } @@ -264,11 +283,11 @@ export async function mergeOrRefetchSchedulePlanPlacesFromItems( const prev = queryClient.getQueryData(key); const merged = await mergeScheduleItemsIntoPlanPlaces(prev, items); if (merged) { - queryClient.setQueryData(key, merged); + setSchedulePlanPlaces(queryClient, rid, scheduleId, merged); return; } const places = await buildPlanPlacesFromScheduleItems(items); - queryClient.setQueryData(key, places); + setSchedulePlanPlaces(queryClient, rid, scheduleId, places); } /** cross-day item 이동 후 source/target 일차 items·route 캐시 동기화 */ @@ -708,7 +727,7 @@ export async function createScheduleItemAtPlanIndex( const createdPlace = await planPlaceFromScheduleItem(response.createdItem); const merged = mergeCreatedScheduleItemDelta(prev, createdPlace, response); if (merged) { - queryClient.setQueryData(key, merged); + setSchedulePlanPlaces(queryClient, rid, args.scheduleId, merged); } else { const fresh = await getScheduleItems(rid, args.scheduleId); await mergeOrRefetchSchedulePlanPlacesFromItems( diff --git a/src/lib/plan/scheduleStompRouteScope.test.ts b/src/lib/plan/scheduleStompRouteScope.test.ts new file mode 100644 index 00000000..08b4e576 --- /dev/null +++ b/src/lib/plan/scheduleStompRouteScope.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { segmentSourcesWithChangedNext } from "@/lib/plan/scheduleStompRouteScope"; + +const sorted = (ids: number[]) => [...ids].sort((a, b) => a - b); + +describe("segmentSourcesWithChangedNext", () => { + it("returns nothing when there was no earlier list to route from", () => { + expect(segmentSourcesWithChangedNext(null, [1, 2, 3])).toEqual([]); + }); + + it("returns nothing when the order is unchanged", () => { + expect(segmentSourcesWithChangedNext([1, 2, 3], [1, 2, 3])).toEqual([]); + }); + + it("collects the sources whose next stop changed after a reorder", () => { + // [A,X,B,C] → [A,B,X,C]: A·X·B의 다음 장소가 바뀌었고 C는 마지막이라 구간이 없다 + expect(sorted(segmentSourcesWithChangedNext([1, 9, 2, 3], [1, 2, 9, 3]))).toEqual([ + 1, 2, 9, + ]); + }); + + it("includes the removed stop and its predecessor after a deletion", () => { + expect(sorted(segmentSourcesWithChangedNext([1, 2, 3], [1, 3]))).toEqual([1, 2]); + }); + + it("includes the new stop and its predecessor after an insertion", () => { + expect(sorted(segmentSourcesWithChangedNext([1, 3], [1, 2, 3]))).toEqual([1, 2]); + }); +}); diff --git a/src/lib/plan/scheduleStompRouteScope.ts b/src/lib/plan/scheduleStompRouteScope.ts index 52e2c784..8e69ea1d 100644 --- a/src/lib/plan/scheduleStompRouteScope.ts +++ b/src/lib/plan/scheduleStompRouteScope.ts @@ -33,6 +33,36 @@ export function readOrderedItemIdsFromScheduleItemsCache( return asOrderedItemIds(data ?? null); } +/** `PlanPlace[]` → 순서대로 `itemId` 목록 */ +export function orderedItemIdsFromPlanPlaces( + places: readonly PlanPlace[], +): number[] { + return asOrderedItemIds(places) ?? []; +} + +/** + * 재조회 전후 순서를 비교해 다음 장소가 바뀐 구간의 출발 `itemId`를 모읍니다. + * 이전 캐시가 없으면 받아 둔 구간 경로도 없으므로 비웁니다. + */ +export function segmentSourcesWithChangedNext( + before: number[] | null, + after: number[], +): number[] { + if (!before) return []; + const nextOf = (ids: number[]) => + new Map(ids.map((id, i) => [id, ids[i + 1]])); + const beforeNext = nextOf(before); + const afterNext = nextOf(after); + const changed = new Set(); + for (const [id, next] of beforeNext) { + if (afterNext.get(id) !== next) changed.add(id); + } + for (const [id, next] of afterNext) { + if (beforeNext.get(id) !== next) changed.add(id); + } + return [...changed]; +} + /** 리오더 API 응답 `RoomScheduleItem[]` → `orderIndex` 순 `itemId` 목록 */ export function orderedItemIdsFromRoomScheduleItems( items: RoomScheduleItem[], diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 849003ef..dee035b1 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -107,6 +107,11 @@ export function joinRequestsQueryKey(roomId: string | null) { return ["join-requests", roomId] as const; } +/** 모든 방의 입장 요청 조회에 공통 접두 */ +export function joinRequestsRootQueryKey() { + return ["join-requests"] as const; +} + // ─── Schedule items / routes ───────────────────────────────────────────────── export const scheduleItemsQueryKey = ( diff --git a/src/lib/rooms.hydrate.test.ts b/src/lib/rooms.hydrate.test.ts new file mode 100644 index 00000000..2e88de48 --- /dev/null +++ b/src/lib/rooms.hydrate.test.ts @@ -0,0 +1,98 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { RoomScheduleWithItems } from "@/lib/api/rooms/schedules"; +import type { PlanPlace } from "@/lib/plan/types"; +import { roomSchedulesQueryKey, scheduleItemsQueryKey } from "@/lib/query-keys"; + +const mocks = vi.hoisted(() => ({ + getRoomSchedules: vi.fn(), + build: vi.fn(), +})); + +vi.mock("@/lib/api/rooms", async (importOriginal) => ({ + ...(await importOriginal()), + getRoomSchedules: mocks.getRoomSchedules, +})); +vi.mock("@/lib/plan/schedule-bulk-hydration", async (importOriginal) => ({ + ...(await importOriginal()), + buildSchedulePlacesFromSchedulesWithItems: mocks.build, +})); + +import { hydrateRoomSchedulesFromServer, refreshRoomSchedules } from "@/lib/rooms"; + +const ROOM_ID = "room-1"; +const SERVER_SCHEDULES: RoomScheduleWithItems[] = [ + { scheduleId: 1, roomId: ROOM_ID, dayNumber: 1, date: "2026-10-25", createdAt: "", items: [] }, +]; +const SERVER_PLACES: PlanPlace[] = [{ id: "item-7", itemId: 7, title: "B" }]; +const CACHED_PLACES: PlanPlace[] = [{ id: "item-5", itemId: 5, title: "A" }]; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("hydrateRoomSchedulesFromServer", () => { + it("does not overwrite caches with a snapshot whose fetch was cancelled", async () => { + const client = new QueryClient(); + client.setQueryData(scheduleItemsQueryKey(ROOM_ID, 1), CACHED_PLACES); + const response = deferred(); + mocks.getRoomSchedules.mockReturnValueOnce(response.promise); + mocks.build.mockResolvedValueOnce(new Map([[1, SERVER_PLACES]])); + const controller = new AbortController(); + + const pending = hydrateRoomSchedulesFromServer(client, ROOM_ID, controller.signal); + controller.abort(); + response.resolve(SERVER_SCHEDULES); + await pending; + + expect(client.getQueryData(scheduleItemsQueryKey(ROOM_ID, 1))).toBe(CACHED_PLACES); + expect(client.getQueryData(roomSchedulesQueryKey(ROOM_ID))).toBeUndefined(); + client.clear(); + }); + + it("writes the day lists once the snapshot is current", async () => { + const client = new QueryClient(); + mocks.getRoomSchedules.mockResolvedValueOnce(SERVER_SCHEDULES); + mocks.build.mockResolvedValueOnce(new Map([[1, SERVER_PLACES]])); + + await hydrateRoomSchedulesFromServer(client, ROOM_ID, new AbortController().signal); + + expect(client.getQueryData(scheduleItemsQueryKey(ROOM_ID, 1))).toEqual(SERVER_PLACES); + expect(client.getQueryData(roomSchedulesQueryKey(ROOM_ID))).toEqual(SERVER_SCHEDULES); + client.clear(); + }); +}); + +describe("refreshRoomSchedules", () => { + it("cancels an in-flight snapshot so only the refresh started after the change is written", async () => { + const client = new QueryClient(); + const stale = deferred(); + mocks.getRoomSchedules + .mockReturnValueOnce(stale.promise) + .mockResolvedValueOnce(SERVER_SCHEDULES); + mocks.build.mockResolvedValue(new Map([[1, SERVER_PLACES]])); + const staleFetch = client + .fetchQuery({ + queryKey: roomSchedulesQueryKey(ROOM_ID), + queryFn: ({ signal }) => hydrateRoomSchedulesFromServer(client, ROOM_ID, signal), + }) + .catch(() => null); + + await refreshRoomSchedules(client, ROOM_ID); + stale.resolve([]); + await staleFetch; + + expect(client.getQueryData(roomSchedulesQueryKey(ROOM_ID))).toEqual(SERVER_SCHEDULES); + expect(client.getQueryData(scheduleItemsQueryKey(ROOM_ID, 1))).toEqual(SERVER_PLACES); + client.clear(); + }); +}); diff --git a/src/lib/rooms.ts b/src/lib/rooms.ts index 1044a6a5..02c4ea60 100644 --- a/src/lib/rooms.ts +++ b/src/lib/rooms.ts @@ -10,7 +10,10 @@ import { getRoomDetail, getRoomSchedules } from "@/lib/api/rooms"; import type { RoomScheduleWithItems } from "@/lib/api/rooms/schedules"; import { apiUrl } from "@/lib/api/http"; import { clearPersistedScheduleRoutesForSchedule } from "@/lib/plan/planTravelLocalStorage"; -import { hydrateScheduleItemsFromSchedulesWithItems } from "@/lib/plan/schedule-bulk-hydration"; +import { + buildSchedulePlacesFromSchedulesWithItems, + writeSchedulePlacesFromHydrate, +} from "@/lib/plan/schedule-bulk-hydration"; import { sortRoomSchedules } from "@/lib/plan/scheduleMerge"; import type { PlanPlace } from "@/lib/plan/types"; import { @@ -96,21 +99,29 @@ export function selectHostRoom( } /** - * `GET /rooms/{roomId}/schedules`로 서버 일정을 **항상** 재조회해 React Query 캐시에 넣습니다. - * `fetchQuery`는 `staleTime: Infinity` 캐시를 재사용할 수 있어 일차 이동(dayNumber shift) 후 - * 순서가 UI에 반영되지 않을 수 있으므로 API를 직접 호출합니다. + * `room-schedules`의 queryFn — `GET /rooms/{roomId}/schedules?includeItems=true`로 일차 목록과 + * 일차별 장소(`schedule-items`) 캐시를 함께 채웁니다. 직접 부르지 말고 조회(`useRoomSchedules`· + * `refreshRoomSchedules`)를 거쳐야 진행 중인 옛 조회가 취소됩니다. * 신규 일차만 항목 캐시를 `[]`로 시딩하고, 기존 scheduleId의 항목 캐시는 보존합니다. */ export async function hydrateRoomSchedulesFromServer( queryClient: QueryClient, roomId: string, + signal?: AbortSignal, ): Promise { const rid = roomId.trim(); if (!rid.length) return []; - const schedules = await getRoomSchedules(rid, { includeItems: true }); + const schedules = await getRoomSchedules(rid, { includeItems: true, signal }); const sorted = sortRoomSchedules(schedules); + const placesBySchedule = await buildSchedulePlacesFromSchedulesWithItems( + queryClient, + sorted, + ); + // 더 늦게 시작한 조회가 이 조회를 취소했으면 이 응답은 옛 스냅샷이다 — 캐시를 덮지 않는다 + if (signal?.aborted) return sorted; + // 미리보기를 기다리는 동안 들어온 변경을 덮지 않도록 캐시는 여기서 한 번에 쓴다 const prevSchedules = queryClient.getQueryData( roomSchedulesQueryKey(rid), @@ -129,11 +140,7 @@ export async function hydrateRoomSchedulesFromServer( } } - await hydrateScheduleItemsFromSchedulesWithItems( - queryClient, - rid, - sorted as RoomScheduleWithItems[], - ); + writeSchedulePlacesFromHydrate(queryClient, rid, placesBySchedule); for (const s of sorted) { const key = scheduleItemsQueryKey(rid, s.scheduleId); @@ -155,11 +162,34 @@ export async function awaitRoomSchedulesHydrated( return queryClient.fetchQuery({ queryKey: roomSchedulesQueryKey(rid), - queryFn: () => hydrateRoomSchedulesFromServer(queryClient, rid), + queryFn: ({ signal }) => + hydrateRoomSchedulesFromServer(queryClient, rid, signal), staleTime: Infinity, }); } +/** + * 일정 전체를 다시 받습니다(본인의 일차 생성·삭제·이동, 여행 날짜 변경 직후). + * 진행 중이던 조회는 이 변경 전 스냅샷이라 취소하고, `room-schedules` 조회 하나로 받아 + * 마지막에 시작한 조회만 캐시에 쓰게 합니다. + */ +export async function refreshRoomSchedules( + queryClient: QueryClient, + roomId: string, +): Promise { + const rid = roomId.trim(); + if (!rid.length) return []; + + const queryKey = roomSchedulesQueryKey(rid); + await queryClient.cancelQueries({ queryKey, exact: true }); + return queryClient.fetchQuery({ + queryKey, + queryFn: ({ signal }) => + hydrateRoomSchedulesFromServer(queryClient, rid, signal), + staleTime: 0, + }); +} + /** `GET /rooms/{roomId}` 결과로 room-detail·방 목록 캐시를 맞춥니다. */ export async function syncRoomDetailFromServer( queryClient: QueryClient, diff --git a/src/lib/stomp/client.test.ts b/src/lib/stomp/client.test.ts new file mode 100644 index 00000000..0e21e177 --- /dev/null +++ b/src/lib/stomp/client.test.ts @@ -0,0 +1,18 @@ +import { ReconnectionTimeMode } from "@stomp/stompjs"; +import { describe, expect, it } from "vitest"; + +import { + createStompClient, + STOMP_MAX_RECONNECT_DELAY_MS, +} from "@/lib/stomp/client"; + +describe("createStompClient", () => { + it("delegates reconnecting to stompjs with a capped exponential backoff", () => { + const client = createStompClient("http://fixture.invalid/ws"); + + expect(client.reconnectTimeMode).toBe(ReconnectionTimeMode.EXPONENTIAL); + expect(client.maxReconnectDelay).toBe(STOMP_MAX_RECONNECT_DELAY_MS); + expect(client.reconnectDelay).toBeGreaterThanOrEqual(1_000); + expect(client.reconnectDelay).toBeLessThan(2_000); + }); +}); diff --git a/src/lib/stomp/client.ts b/src/lib/stomp/client.ts index 9a87f56a..e36e37bb 100644 --- a/src/lib/stomp/client.ts +++ b/src/lib/stomp/client.ts @@ -1,8 +1,14 @@ -import { Client } from "@stomp/stompjs"; +import { Client, ReconnectionTimeMode } from "@stomp/stompjs"; import SockJS from "sockjs-client"; import { clientEnv } from "@/lib/client-env"; +/** 재연결 간격 상한 */ +export const STOMP_MAX_RECONNECT_DELAY_MS = 30_000; + +/** 끊긴 뒤 다시 연결을 이만큼 실패하면 멈춘다. 간격 합으로 약 1분 30초~2분 */ +export const STOMP_MAX_RECONNECT_ATTEMPTS = 7; + /** * NEXT_PUBLIC_API_BASE_URL 기반으로 SockJS HTTP URL을 반환합니다. * 백엔드가 .withSockJS()로 설정되어 있으므로 ws:// 대신 http:// 를 사용합니다. @@ -11,9 +17,13 @@ export function getStompBrokerURL(): string { return `${clientEnv.NEXT_PUBLIC_API_BASE_URL}/ws`; } +/** 끊기면 stompjs가 지수 간격(1→2→4…초, 상한 30초)으로 다시 연결한다. 횟수 제한은 연결 effect가 맡는다 */ export function createStompClient(httpURL: string): Client { return new Client({ webSocketFactory: () => new SockJS(httpURL), - reconnectDelay: 0, + // 시작 간격을 탭마다 1~2초로 흩어, 함께 끊긴 탭들이 같은 순간 다시 붙지 않게 한다 + reconnectDelay: 1_000 + Math.random() * 1_000, + reconnectTimeMode: ReconnectionTimeMode.EXPONENTIAL, + maxReconnectDelay: STOMP_MAX_RECONNECT_DELAY_MS, }); } diff --git a/src/lib/stomp/forced-room-exit-dispatch.ts b/src/lib/stomp/forced-room-exit-dispatch.ts index 2ea701fd..5fc9cf65 100644 --- a/src/lib/stomp/forced-room-exit-dispatch.ts +++ b/src/lib/stomp/forced-room-exit-dispatch.ts @@ -39,6 +39,14 @@ export function showRoomLeftToast(roomId: string): void { toast.success("방에서 나갔어요.", { id: `room-left-${roomId.trim()}` }); } +/** 끊긴 동안 강퇴·방 삭제·다른 기기의 나가기를 놓쳐 접근을 잃은 경우 — 사유를 알 수 없어 중립 문구로 알린다 */ +export function showRoomAccessLostToast(roomId: string): void { + toast.error("방에 더 이상 접근할 수 없어요.", { + id: `room-access-lost-${roomId.trim()}`, + duration: FORCED_ROOM_EXIT_TOAST_MS, + }); +} + /** 강퇴·방 삭제 시 홈 목록·방 단위 React Query 캐시에서 해당 roomId 제거 */ export function evictRoomFromClientCaches( queryClient: QueryClient, diff --git a/src/lib/stomp/members-dispatch.test.ts b/src/lib/stomp/members-dispatch.test.ts new file mode 100644 index 00000000..c915d8fb --- /dev/null +++ b/src/lib/stomp/members-dispatch.test.ts @@ -0,0 +1,76 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { RoomMember, RoomMemberListResponse } from "@/lib/api/rooms"; +import { roomMembersQueryKey } from "@/lib/query-keys"; +import type { RoomMemberPayload } from "@/lib/stomp/member-events"; +import { dispatchRoomMemberEvent } from "@/lib/stomp/members-dispatch"; +import { dispatchRoomPresence } from "@/lib/stomp/presence-dispatch"; + +const ROOM_ID = "room"; +const KEY = roomMembersQueryKey(ROOM_ID); + +const member = (userId: number, isOnline = false): RoomMember => ({ + userId, + nickname: `user-${userId}`, + profileImageUrl: null, + role: "MEMBER", + status: "ACTIVE", + joinedAt: "2026-10-25T00:00:00Z", + isOnline, +}); + +const joined = (userId: number): RoomMemberPayload => ({ + type: "MEMBER_JOINED", + id: `event-${userId}`, + roomId: ROOM_ID, + createdAt: "2026-10-25T00:00:00Z", + detail: { userId, nickname: `user-${userId}` }, +}); + +const clients: QueryClient[] = []; +function clientWith(data?: RoomMemberListResponse) { + const client = new QueryClient(); + clients.push(client); + if (data) client.setQueryData(KEY, data); + return client; +} + +afterEach(() => { + for (const client of clients.splice(0)) client.clear(); +}); + +describe("member cache patches", () => { + it("applies a member event to a valid list", () => { + const client = clientWith({ members: [member(1)] }); + + dispatchRoomMemberEvent(client, ROOM_ID, joined(2)); + + expect(client.getQueryData(KEY)?.members.map((m) => m.userId)).toEqual([1, 2]); + }); + + it("keeps the pending refetch of a list invalidated while off screen", async () => { + const client = clientWith({ members: [member(1)] }); + await client.invalidateQueries({ queryKey: KEY, refetchType: "none" }); + + dispatchRoomMemberEvent(client, ROOM_ID, joined(2)); + dispatchRoomPresence(client, ROOM_ID, { + type: "USER_CONNECTED", + userId: 1, + roomId: ROOM_ID, + nickname: null, + profileImageUrl: null, + }); + + expect(client.getQueryState(KEY)?.isInvalidated).toBe(true); + expect(client.getQueryData(KEY)).toEqual({ members: [member(1)] }); + }); + + it("does not build a partial list before the member list was ever loaded", () => { + const client = clientWith(); + + dispatchRoomMemberEvent(client, ROOM_ID, joined(2)); + + expect(client.getQueryData(KEY)).toBeUndefined(); + }); +}); diff --git a/src/lib/stomp/members-dispatch.tsx b/src/lib/stomp/members-dispatch.tsx index 0a8b8ca7..572b4b1c 100644 --- a/src/lib/stomp/members-dispatch.tsx +++ b/src/lib/stomp/members-dispatch.tsx @@ -80,15 +80,22 @@ export function dispatchRoomMemberEvent( if (!rid) return; void queryClient.invalidateQueries({ queryKey: expenseKeys.members(rid) }); + // 무효화돼 다시 받을 목록은 패치하지 않는다 — setQueryData가 '갱신 필요' 표시를 지워 재조회가 사라진다. + // 방 목록 역할(HOST_DELEGATED)은 따로 맞춘다 + const membersAwaitingRefetch = + queryClient.getQueryState(roomMembersQueryKey(rid))?.isInvalidated === true; + switch (event.type) { case "MEMBER_JOINED": { const d = event.detail; - if (!d) return; + if (!d || membersAwaitingRefetch) return; queryClient.setQueryData( roomMembersQueryKey(rid), (prev) => { - const members = prev?.members ?? []; + // 목록을 받은 적이 없으면 한 명짜리 목록을 만들지 않고 처음 조회에 맡긴다 + if (!prev) return prev; + const members = prev.members; const joinedAt = event.createdAt.trim().length > 0 ? event.createdAt @@ -111,7 +118,7 @@ export function dispatchRoomMemberEvent( isOnline: true, }, ); - return prev ? { ...prev, members: next } : { members: next }; + return { ...prev, members: next }; }, ); break; @@ -120,12 +127,13 @@ export function dispatchRoomMemberEvent( case "MEMBER_LEFT": case "MEMBER_KICKED": { const d = event.detail; - if (!d) return; + if (!d || membersAwaitingRefetch) return; queryClient.setQueryData( roomMembersQueryKey(rid), (prev) => { - const members = prev?.members ?? []; + if (!prev) return prev; + const members = prev.members; const idx = members.findIndex((m) => m.userId === d.userId); const display = memberDisplayPatch(d); @@ -138,7 +146,7 @@ export function dispatchRoomMemberEvent( status: "LEFT", isOnline: false, }; - return prev ? { ...prev, members: next } : { members: next }; + return { ...prev, members: next }; } const leftMember: RoomMember = { @@ -153,9 +161,7 @@ export function dispatchRoomMemberEvent( : new Date().toISOString(), isOnline: false, }; - return { - members: [...members, leftMember], - }; + return { ...prev, members: [...members, leftMember] }; }, ); break; @@ -165,22 +171,24 @@ export function dispatchRoomMemberEvent( const d = event.detail; if (!d) return; - queryClient.setQueryData( - roomMembersQueryKey(rid), - (prev) => { - if (!prev?.members?.length) return prev; - const members = prev.members.map((m) => { - if (m.userId === d.previousHostUserId) { - return { ...m, role: "MEMBER" as const }; - } - if (m.userId === d.newHostUserId) { - return { ...m, role: "HOST" as const }; - } - return m; - }); - return { ...prev, members }; - }, - ); + if (!membersAwaitingRefetch) { + queryClient.setQueryData( + roomMembersQueryKey(rid), + (prev) => { + if (!prev?.members?.length) return prev; + const members = prev.members.map((m) => { + if (m.userId === d.previousHostUserId) { + return { ...m, role: "MEMBER" as const }; + } + if (m.userId === d.newHostUserId) { + return { ...m, role: "HOST" as const }; + } + return m; + }); + return { ...prev, members }; + }, + ); + } patchRoomsRoleForHostDelegation( queryClient, diff --git a/src/lib/stomp/presence-dispatch.tsx b/src/lib/stomp/presence-dispatch.tsx index 96789894..0c8f85d8 100644 --- a/src/lib/stomp/presence-dispatch.tsx +++ b/src/lib/stomp/presence-dispatch.tsx @@ -21,6 +21,11 @@ function patchMemberOnline( const rid = roomId.trim(); if (!rid.length) return false; + // 무효화돼 다시 받을 목록은 패치하지 않는다 — setQueryData가 '갱신 필요' 표시를 지워 재조회가 사라진다 + if (queryClient.getQueryState(roomMembersQueryKey(rid))?.isInvalidated) { + return false; + } + const cached = queryClient.getQueryData( roomMembersQueryKey(rid), ); diff --git a/src/lib/stomp/room-resync-dispatch.test.ts b/src/lib/stomp/room-resync-dispatch.test.ts new file mode 100644 index 00000000..719f95a0 --- /dev/null +++ b/src/lib/stomp/room-resync-dispatch.test.ts @@ -0,0 +1,77 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { afterEach, describe, expect, it } from "vitest"; + +import { expenseKeys } from "@/lib/expenses/expense-queries"; +import { + bookmarkCategoriesQueryKey, + joinRequestsQueryKey, + roomSchedulesQueryKey, + roomUnreadCountQueryKey, +} from "@/lib/query-keys"; +import { + resyncRoomDataAfterSubscribe, + resyncUserQueueDataAfterSubscribe, +} from "./room-resync-dispatch"; + +const clients: QueryClient[] = []; +function newClient() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + return client; +} + +afterEach(() => { + for (const client of clients.splice(0)) client.clear(); +}); + +describe("resyncRoomDataAfterSubscribe", () => { + it("refetches shared room data on screen and only marks the rest for its next mount", async () => { + const client = newClient(); + let scheduleFetches = 0; + const observer = new QueryObserver(client, { + queryKey: roomSchedulesQueryKey("room"), + queryFn: async () => { + scheduleFetches += 1; + return []; + }, + staleTime: Infinity, + }); + const stop = observer.subscribe(() => {}); + await observer.refetch(); + client.setQueryData(bookmarkCategoriesQueryKey("room"), []); + client.setQueryData(roomUnreadCountQueryKey("room"), { unreadCount: 0 }); + + resyncRoomDataAfterSubscribe(client, "room"); + + await expect.poll(() => scheduleFetches).toBe(2); + expect(client.getQueryState(bookmarkCategoriesQueryKey("room"))?.isInvalidated).toBe(true); + expect(client.getQueryState(roomUnreadCountQueryKey("room"))?.isInvalidated).toBe(true); + stop(); + }); + + it("leaves other rooms and data that recovers on its own untouched", () => { + const client = newClient(); + client.setQueryData(roomSchedulesQueryKey("other"), []); + client.setQueryData(expenseKeys.room("room"), []); + client.setQueryData(["packing", "room", 1], []); + + resyncRoomDataAfterSubscribe(client, "room"); + + expect(client.getQueryState(roomSchedulesQueryKey("other"))?.isInvalidated).toBe(false); + expect(client.getQueryState(expenseKeys.room("room"))?.isInvalidated).toBe(false); + expect(client.getQueryState(["packing", "room", 1])?.isInvalidated).toBe(false); + }); +}); + +describe("resyncUserQueueDataAfterSubscribe", () => { + it("marks every room's join requests for refetch", () => { + const client = newClient(); + client.setQueryData(joinRequestsQueryKey("a"), []); + client.setQueryData(joinRequestsQueryKey("b"), []); + + resyncUserQueueDataAfterSubscribe(client); + + expect(client.getQueryState(joinRequestsQueryKey("a"))?.isInvalidated).toBe(true); + expect(client.getQueryState(joinRequestsQueryKey("b"))?.isInvalidated).toBe(true); + }); +}); diff --git a/src/lib/stomp/room-resync-dispatch.ts b/src/lib/stomp/room-resync-dispatch.ts new file mode 100644 index 00000000..bf922a99 --- /dev/null +++ b/src/lib/stomp/room-resync-dispatch.ts @@ -0,0 +1,40 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { + bookmarkCategoriesQueryKey, + joinRequestsRootQueryKey, + roomAllBookmarksQueryKey, + roomBookmarksByRoomRootQueryKey, + roomSchedulesQueryKey, + roomUnreadCountQueryKey, +} from "@/lib/query-keys"; + +/** + * 방 토픽을 (다시) 구독한 직후 — 구독하지 않던 동안(첫 연결 전·재연결·`/home` 이탈·방 전환) 놓친 + * 브로드캐스트를 복구하려고 방 공용 데이터를 무효화한다. 화면에 있는 조회만 바로 다시 받고 + * 나머지는 다시 볼 때 받는다. 서버(simple broker)는 구독 성립을 알리지 않아 SUBSCRIBE를 보낸 직후에 + * 하므로 아주 짧은 틈은 남는다. 멤버는 presence 재동기화가, 가계부·채팅 목록은 각자 복구하고, + * 준비물은 개인 데이터라 넣지 않는다. + */ +export function resyncRoomDataAfterSubscribe( + queryClient: QueryClient, + roomId: string, +): void { + const rid = roomId.trim(); + if (!rid.length) return; + + for (const queryKey of [ + roomSchedulesQueryKey(rid), + bookmarkCategoriesQueryKey(rid), + roomBookmarksByRoomRootQueryKey(rid), + roomAllBookmarksQueryKey(rid), + roomUnreadCountQueryKey(rid), + ]) { + void queryClient.invalidateQueries({ queryKey }); + } +} + +/** 개인 큐(`/user/queue/rooms`)를 (다시) 구독한 직후 — 구독하지 않던 동안 놓친 입장 요청을 복구한다 */ +export function resyncUserQueueDataAfterSubscribe(queryClient: QueryClient): void { + void queryClient.invalidateQueries({ queryKey: joinRequestsRootQueryKey() }); +} diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index 5f5663b1..2001055b 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -45,7 +45,6 @@ vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ })); vi.mock("@/lib/rooms", () => ({ - hydrateRoomSchedulesFromServer: vi.fn(), syncRoomDetailFromServer: vi.fn(), })); diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index cfa53dd2..b359bd37 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -12,10 +12,8 @@ import { invalidateScheduleItemRouteForWholeSchedule, removeRouteQueriesForDeletedItemSource, } from "@/lib/plan/scheduleStompRouteScope"; -import { - hydrateRoomSchedulesFromServer, - syncRoomDetailFromServer, -} from "@/lib/rooms"; +import { roomSchedulesQueryKey } from "@/lib/query-keys"; +import { syncRoomDetailFromServer } from "@/lib/rooms"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; import { readSessionUserId } from "@/lib/session-user-cache"; import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; @@ -230,7 +228,8 @@ export async function dispatchRoomScheduleEvent( return; } - await hydrateRoomSchedulesFromServer(queryClient, rid); + // 일정 전체 재조회는 room-schedules 조회 하나로 한다 — 진행 중인 옛 조회가 취소돼 새 일정을 덮지 않는다 + await queryClient.invalidateQueries({ queryKey: roomSchedulesQueryKey(rid) }); await syncRoomDetailFromServer(queryClient, rid); return; } diff --git a/src/lib/stomp/stomp-session-recovery.test.ts b/src/lib/stomp/stomp-session-recovery.test.ts new file mode 100644 index 00000000..a7f2c55c --- /dev/null +++ b/src/lib/stomp/stomp-session-recovery.test.ts @@ -0,0 +1,104 @@ +import { QueryClient } from "@tanstack/react-query"; +import type { Client } from "@stomp/stompjs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + expire: vi.fn(), + fetchUser: vi.fn(), + setUser: vi.fn(), +})); + +vi.mock("@/lib/auth-session-client", () => ({ + expireClientSessionAndRedirect: mocks.expire, +})); +vi.mock("@/lib/session-user", () => ({ + fetchSessionUserRaw: mocks.fetchUser, +})); +vi.mock("@/lib/session-user-cache", () => ({ + setSessionUserCache: mocks.setUser, +})); + +import { refreshSessionBeforeReconnect } from "@/lib/stomp/stomp-session-recovery"; + +const fetchMock = vi.fn(); + +function setup(isActive = true) { + const client = { deactivate: vi.fn(() => Promise.resolve()) }; + const queryClient = new QueryClient(); + const run = () => + refreshSessionBeforeReconnect({ + client: client as unknown as Client, + queryClient, + isActive: () => isActive, + }); + return { client, queryClient, run }; +} + +beforeEach(() => { + // probeClientSession은 브라우저에서만 세션을 확인한다 + vi.stubGlobal("window", {}); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockReset(); + vi.clearAllMocks(); +}); + +describe("refreshSessionBeforeReconnect", () => { + it("stops reconnecting and expires the client session when the session is gone", async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 })); + const { client, queryClient, run } = setup(); + + await run(); + + expect(client.deactivate).toHaveBeenCalledOnce(); + expect(mocks.expire).toHaveBeenCalledWith({ queryClient }); + }); + + it("syncs the session user before reconnecting with a valid session", async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })); + const user = { id: 1 }; + mocks.fetchUser.mockResolvedValueOnce(user); + const { client, queryClient, run } = setup(); + + await run(); + + expect(mocks.setUser).toHaveBeenCalledWith(queryClient, user); + expect(client.deactivate).not.toHaveBeenCalled(); + }); + + it.each([503, 500])( + "lets stompjs attempt the connection when the session check returns %i", + async (status) => { + fetchMock.mockResolvedValueOnce(new Response(null, { status })); + const { client, run } = setup(); + + await run(); + + expect(client.deactivate).not.toHaveBeenCalled(); + expect(mocks.expire).not.toHaveBeenCalled(); + }, + ); + + it("treats a network failure as transient", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")); + const { client, run } = setup(); + + await expect(run()).resolves.toBeUndefined(); + + expect(client.deactivate).not.toHaveBeenCalled(); + expect(mocks.expire).not.toHaveBeenCalled(); + }); + + it("does nothing when the connection was torn down while checking", async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 })); + const { client, run } = setup(false); + + await run(); + + expect(client.deactivate).not.toHaveBeenCalled(); + expect(mocks.expire).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/stomp/stomp-session-recovery.ts b/src/lib/stomp/stomp-session-recovery.ts index 9998fd89..5d833597 100644 --- a/src/lib/stomp/stomp-session-recovery.ts +++ b/src/lib/stomp/stomp-session-recovery.ts @@ -4,17 +4,12 @@ import type { Client } from "@stomp/stompjs"; import { expireClientSessionAndRedirect } from "@/lib/auth-session-client"; import { fetchSessionUserRaw } from "@/lib/session-user"; import { setSessionUserCache } from "@/lib/session-user-cache"; -import { - isStompAutoRecoverySuspended, - STOMP_CONNECTION_ISSUE_MESSAGE, - useStompConnectionStore, -} from "@/stores/stomp-connection-store"; /** 백엔드 access 만료 시 WebSocket close code (분기·로깅 참고용) */ export const STOMP_ACCESS_TOKEN_EXPIRED_CLOSE_CODE = 4001; -export const SESSION_PROBE_MAX_ATTEMPTS = 10; -export const SESSION_PROBE_RETRY_DELAY_MS = 5000; +/** 세션 확인이 응답 없이 매달리면 재연결 시도 전체가 멈추므로 시간을 제한한다 */ +const SESSION_PROBE_TIMEOUT_MS = 5_000; export type SessionProbeOutcome = "authenticated" | "unauthorized" | "transient"; @@ -22,31 +17,28 @@ export type SessionProbeResult = { outcome: SessionProbeOutcome; }; -export type HandleStompReconnectOptions = { +export type RefreshSessionBeforeReconnectOptions = { client: Client; queryClient: QueryClient; - isSuppressed: () => boolean; + /** 세션 확인을 기다리는 동안 연결이 정리됐는지(로그아웃·경로 이탈) */ isActive: () => boolean; }; -let reconnectPromise: Promise | null = null; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - /** - * `GET /api/auth/session` — 200 / 401 / transient(5xx·네트워크·기타 4xx) 분기. + * `GET /api/auth/session` — 200 / 401 / transient(503·네트워크·시간 초과·기타) 분기. */ export async function probeClientSession(): Promise { if (typeof window === "undefined") { return { outcome: "transient" }; } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SESSION_PROBE_TIMEOUT_MS); try { const res = await fetch("/api/auth/session", { credentials: "include", cache: "no-store", + signal: controller.signal, }); if (res.status === 200) { @@ -55,28 +47,14 @@ export async function probeClientSession(): Promise { if (res.status === 401) { return { outcome: "unauthorized" }; } - if (res.status >= 500) { - return { outcome: "transient" }; - } return { outcome: "transient" }; } catch { return { outcome: "transient" }; + } finally { + clearTimeout(timeout); } } -async function probeWithRetries(): Promise { - for (let attempt = 1; attempt <= SESSION_PROBE_MAX_ATTEMPTS; attempt++) { - const result = await probeClientSession(); - if (result.outcome !== "transient") { - return result; - } - if (attempt < SESSION_PROBE_MAX_ATTEMPTS) { - await sleep(SESSION_PROBE_RETRY_DELAY_MS); - } - } - return { outcome: "transient" }; -} - async function syncSessionUserCache(queryClient: QueryClient): Promise { try { const user = await fetchSessionUserRaw(); @@ -93,66 +71,31 @@ export function handleStompSessionExpired(queryClient: QueryClient): void { expireClientSessionAndRedirect({ queryClient }); } -async function runStompReconnect({ +/** + * stompjs `beforeConnect`에서 재연결 직전에 부릅니다. + * `/api/auth/session`이 쿠키를 갱신해, access 만료(4001) 뒤 만료된 쿠키로 다시 붙지 않게 합니다. + * 재시도 간격은 stompjs가 맡으므로 한 번만 확인합니다. 안에서 부르는 함수는 예외를 던지지 않습니다. + * stompjs는 `beforeConnect` 예외를 잡지 않아 던지면 재연결이 멈추기 때문입니다. + */ +export async function refreshSessionBeforeReconnect({ client, queryClient, - isSuppressed, isActive, -}: HandleStompReconnectOptions): Promise { - if (!isActive() || isSuppressed() || isStompAutoRecoverySuspended()) { - return; - } - - const probe = await probeWithRetries(); - - if (!isActive() || isSuppressed()) { +}: RefreshSessionBeforeReconnectOptions): Promise { + const probe = await probeClientSession(); + if (!isActive()) { return; } if (probe.outcome === "unauthorized") { - handleStompSessionExpired(queryClient); - return; - } - - if (probe.outcome === "transient") { - useStompConnectionStore - .getState() - .setConnectionIssue(STOMP_CONNECTION_ISSUE_MESSAGE); - return; - } - - useStompConnectionStore.getState().clearConnectionIssue(); - await syncSessionUserCache(queryClient); - - if (!isActive() || isSuppressed()) { - return; - } - - if (client.active) { await client.deactivate(); - } - - if (!isActive() || isSuppressed()) { + handleStompSessionExpired(queryClient); return; } - client.activate(); -} - -/** - * WebSocket close 후 session 검증 → deactivate/activate 재연결 또는 로그아웃·장애 UI. - * 단일 in-flight promise로 동시 호출을 합칩니다. - */ -export function handleStompReconnect( - options: HandleStompReconnectOptions, -): Promise { - if (reconnectPromise) { - return reconnectPromise; + if (probe.outcome === "authenticated") { + // 다른 탭에서 계정이 바뀌었으면 여기서 세션 사용자 캐시가 새 사용자로 바뀐다 + await syncSessionUserCache(queryClient); } - - reconnectPromise = runStompReconnect(options).finally(() => { - reconnectPromise = null; - }); - - return reconnectPromise; + // transient면 그대로 연결을 시도한다. 실패하면 stompjs가 다음 간격에 다시 부른다 } diff --git a/src/lib/stomp/subscribe-room-topics.test.ts b/src/lib/stomp/subscribe-room-topics.test.ts index c39259f1..a07782be 100644 --- a/src/lib/stomp/subscribe-room-topics.test.ts +++ b/src/lib/stomp/subscribe-room-topics.test.ts @@ -1,7 +1,7 @@ import type { Client, IMessage } from "@stomp/stompjs"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { expect, it, vi } from "vitest"; -import { roomUnreadCountQueryKey, sessionUserQueryKey } from "@/lib/query-keys"; +import { roomSchedulesQueryKey, roomUnreadCountQueryKey, sessionUserQueryKey } from "@/lib/query-keys"; import { subscribeRoomStompTopics } from "./subscribe-room-topics"; vi.mock("@/lib/stomp/bookmarks-dispatch", () => ({ applyRoomBookmarkStompMessage: vi.fn() })); @@ -75,3 +75,18 @@ it("본인 MEMBER_LEFT만 onSelfLeftRoom으로 알린다 — 다른 탭에서 queryClient.clear(); } }); + +it("구독할 때마다 구독하지 않던 동안 놓친 일정 변경을 받도록 일정을 무효화한다", () => { + const client = { + subscribe: () => ({ unsubscribe: () => {} }), + } as unknown as Client; + const queryClient = new QueryClient(); + queryClient.setQueryData(roomSchedulesQueryKey("room"), []); + const unsubscribe = subscribeRoomStompTopics(client, "room", { current: queryClient }, {}); + try { + expect(queryClient.getQueryState(roomSchedulesQueryKey("room"))?.isInvalidated).toBe(true); + } finally { + unsubscribe(); + queryClient.clear(); + } +}); diff --git a/src/lib/stomp/subscribe-room-topics.ts b/src/lib/stomp/subscribe-room-topics.ts index ef354c9d..c307cb12 100644 --- a/src/lib/stomp/subscribe-room-topics.ts +++ b/src/lib/stomp/subscribe-room-topics.ts @@ -14,6 +14,7 @@ import { optimisticPatchSelfOnline, scheduleRoomMembersResyncAfterSubscribe, } from "@/lib/stomp/presence-dispatch"; +import { resyncRoomDataAfterSubscribe } from "@/lib/stomp/room-resync-dispatch"; import { parseRoomScheduleMessage } from "@/lib/stomp/schedule-events"; import { dispatchRoomScheduleEvent } from "@/lib/stomp/schedules-dispatch"; import { dispatchUserError } from "@/lib/stomp/user-error-dispatch"; @@ -113,6 +114,7 @@ export function subscribeRoomStompTopics( queryClientRef.current, subscribedRoomId, ); + resyncRoomDataAfterSubscribe(queryClientRef.current, subscribedRoomId); return () => { membersSub.unsubscribe(); diff --git a/src/lib/stomp/subscribe-user-rooms-queue.ts b/src/lib/stomp/subscribe-user-rooms-queue.ts index 6e581f19..a567f0f6 100644 --- a/src/lib/stomp/subscribe-user-rooms-queue.ts +++ b/src/lib/stomp/subscribe-user-rooms-queue.ts @@ -3,6 +3,7 @@ import type { MutableRefObject } from "react"; import type { QueryClient } from "@tanstack/react-query"; import { dispatchHostJoinRequestFromStomp } from "@/lib/stomp/join-requests-dispatch"; +import { resyncUserQueueDataAfterSubscribe } from "@/lib/stomp/room-resync-dispatch"; import { parseUserRoomQueueMessage, type ForcedRoomExitReason, @@ -44,6 +45,7 @@ export function subscribeUserRoomsQueue( parsed.nickname, ); }); + resyncUserQueueDataAfterSubscribe(options.queryClientRef.current); return () => { sub.unsubscribe(); diff --git a/src/stores/stomp-connection-store.ts b/src/stores/stomp-connection-store.ts index 91081bb3..6e7f210b 100644 --- a/src/stores/stomp-connection-store.ts +++ b/src/stores/stomp-connection-store.ts @@ -5,30 +5,18 @@ export const STOMP_CONNECTION_ISSUE_MESSAGE = interface StompConnectionStore { connectionIssue: string | null; - autoRecoverySuspended: boolean; setConnectionIssue: (message: string) => void; clearConnectionIssue: () => void; - resumeAutoRecovery: () => void; } export const useStompConnectionStore = create((set) => ({ connectionIssue: null, - autoRecoverySuspended: false, setConnectionIssue: (message) => { - set({ connectionIssue: message, autoRecoverySuspended: true }); + set({ connectionIssue: message }); }, clearConnectionIssue: () => { - set({ connectionIssue: null, autoRecoverySuspended: false }); - }, - - resumeAutoRecovery: () => { - set({ autoRecoverySuspended: false }); + set({ connectionIssue: null }); }, })); - -/** STOMP 복구 콜백 등 React 밖에서 자동 재시도 중단 여부 확인 */ -export function isStompAutoRecoverySuspended(): boolean { - return useStompConnectionStore.getState().autoRecoverySuspended; -} From f88a7b2b1bf00f4c3b565a80239effbbe7355950 Mon Sep 17 00:00:00 2001 From: KangShingyu <103213494+KangShinGyu98@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:27:50 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix(places):=20=EC=82=AC=EC=A7=84=C2=B7?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=B0=98=EB=B3=B5=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=C2=B7batch=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20(#248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(places): 사진 조회 실패 시 반복 요청하지 않도록 재시도 규칙 변경 - 전역 재시도를 일시적 네트워크 오류 1회로 제한 (429·4xx·5xx는 재시도하지 않음) - 장소 API 실패를 status가 담긴 HttpError로 던짐 - 사진 retry 2·미리보기 retry 1 개별 설정 제거 Co-Authored-By: Claude Opus 5.5 * feat(places): 사진 카드를 batch로 조회하고 실패한 장만 한 번 더 조회 - 같은 커밋에 보이는 사진 2장 이상은 batch, 1장은 단건 API로 보냄 - batch 항목 실패는 실패한 장만 한 번 더 보내고 그래도 실패하면 끝냄 - 요청 자체가 실패하면 단건으로 나눠 보내지 않음 - 쓰이지 않던 사진 batch 시딩 코드 제거 Co-Authored-By: Claude Opus 5.5 * feat(places): 지도 북마크 핀·북마크 미리보기를 batch로 조회 - 같은 커밋에 마운트된 미리보기는 batch로 모으고 실패한 곳만 한 번 더 조회 - 지도 핀이 batch를 먼저 보낸 뒤 실패하면 전부 단건으로 다시 받던 단계 제거 Co-Authored-By: Claude Opus 5.5 * fix(route): 경로 batch의 경로 없음 답을 단건으로 다시 묻지 않음 - NO_ROUTE·LAST_ITEM·SCHEDULE_ITEM_NOT_FOUND 구간은 경로 없음(null)으로 시딩 Co-Authored-By: Claude Opus 5.5 * fix(schedule): 일정 불러오기에서 같은 장소 미리보기를 반복 조회하지 않도록 수정 - 모든 일차 장소를 batch 한 번으로 받고 실패한 곳만 batch로 한 번 더 조회, 단건 조회 제거 - 일차 블록은 일정 불러오기가 끝난 뒤 켜서 같은 장소를 다시 받지 않음 - 자리표시자가 남은 일차는 다시 들어올 때 그 장소만 batch로 다시 조회 - 미리보기 batch가 실패해도 일정 전체를 실패로 만들지 않고 자리표시자로 표시 Co-Authored-By: Claude Opus 5.5 * fix(schedule): 일정 불러오는 동안 일차에 빈 상태 대신 스켈레톤 표시 - 일차 쿼리를 일정 불러오기 중에도 켜고, 진행 중인 불러오기를 기다려 그 결과를 사용 - 같은 진입 안에서 장소 미리보기를 다시 받지 않음 Co-Authored-By: Claude Opus 5.5 * fix(places): 100곳이 넘을 때 뒤 묶음이 실패해도 앞 묶음에서 받은 장소는 쓰도록 수정 - 로더가 서버 한도(100곳)씩 나눠 차례로 보내고, 받은 묶음은 바로 씀 - 요청이 실패하면 그 묶음과 남은 묶음은 그 에러로 끝내고, 실패 항목도 다시 보내지 않음(요청 수는 이전과 같음) - 마지막 묶음이 1곳이면 단건 API로 보냄 Co-Authored-By: Claude Opus 5.5 * fix(schedule): 기다리던 일정 불러오기가 취소되면 새 불러오기를 기다리도록 수정 - 취소된 불러오기를 기다리던 일차 조회가 '조회 중'으로 멈춰 그 일차 경로를 받지 않던 문제 방지 - 진행 중인 일정 불러오기를 취소하고 다시 받는 변경(#247)과 함께 쓰일 때를 대비 Co-Authored-By: Claude Opus 5.5 * fix(places): 사진 batch 응답 id 형식이 달라도 같은 장소로 맞추도록 수정 - 응답 id의 `places/` 접두를 떼고 요청 id 기준으로 결과를 돌려줌 - id가 빈 항목이 있어도 batch 전체를 실패로 처리하지 않음 Co-Authored-By: Claude Opus 5.5 * fix(places): status 없는 미리보기 batch 항목도 id·이름이 있으면 사용 - 레거시 응답(status 없음)을 예전처럼 받아 일정·북마크 핀이 자리표시자로 남지 않게 함 Co-Authored-By: Claude Opus 5.5 * fix(bookmark): 미리보기를 받지 못하면 내부 에러 문구 대신 안내 문구 표시 - 요청 id·API 오류 코드가 담긴 에러 메시지를 화면에 그대로 보여 주지 않음 Co-Authored-By: Claude Opus 5.5 * fix(plan): 사진을 다시 받아도 불러오지 못하면 일정 카드에 깨진 이미지 대신 회색 칸 표시 - 사진 오류 처리에 다시 받기까지 실패했을 때 알리는 `onPhotoUnavailable` 추가 - 못 불러온 사진 주소는 그리지 않는 `useVisiblePlacePhotoUrl` 추가(주소가 바뀌면 다시 그림) Co-Authored-By: Claude Opus 5.5 * fix(places): 북마크·채팅·장소 상세 사진도 불러오지 못하면 사진 없음 모양 표시 - 북마크·채팅 장소 카드는 지도 핀 아이콘, 장소 상세는 회색 칸으로 둠 Co-Authored-By: Claude Opus 5.5 * refactor(places): 사진 조회 옵션을 함수로 빼고 테스트가 훅 대신 쓰게 함 - 훅이 아닌 함수에서 훅을 부르던 테스트의 lint 오류(react-hooks/rules-of-hooks) 수정 - 조회 키·queryFn·캐시 설정은 그대로 Co-Authored-By: Claude Opus 5.5 * test(places): 사진 없음 처리 훅 테스트가 렌더 중 바깥 변수를 바꾸지 않게 수정 - lint 오류(react-hooks/globals) 수정 Co-Authored-By: Claude Opus 5.5 * fix(route): 경로를 다시 받다가 실패해도 마지막 답이 경로 없음이면 오류 대신 그대로 표시 - 순서 변경 등으로 다시 받던 "경로 없음" 구간이 429를 받으면 "경로 조회 오류"로 바뀌던 문제 수정 - 받은 답 없이 실패하면 지금처럼 오류 표시 Co-Authored-By: Claude Opus 5.5 * fix(route): 지도가 방금 실패한 경로 구간을 다시 요청하지 않도록 수정 - 목록 요청이 429 등으로 실패한 구간을 지도가 fetchQuery로 한 번 더 보내던 문제 수정 - 진행 중인 요청에 합류하고, 받은 적 없는 구간은 지금처럼 받음 Co-Authored-By: Claude Opus 5.5 * fix(schedule): 미리보기 실패 상태에서 다시 들어와도 지운 장소가 되살아나지 않도록 수정 - 일차 목록이 있으면 room-schedules의 장소 목록으로 다시 만들지 않고 자리표시자 장소의 미리보기만 다시 받아 채움 - room-schedules의 장소 목록은 장소 추가·삭제·수정을 따라가지 않아, 다시 만들면 지운 장소가 되살아났음 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: minbros --- .../BookmarkFolderDetailView.loading.test.tsx | 33 +- .../_components/BookmarkFolderDetailView.tsx | 7 +- .../_components/BookmarkPlacePreviewCard.tsx | 7 +- .../_components/itinerary/PlanPlaceCard.tsx | 8 +- .../travel-time/PlanTravelTime.test.tsx | 52 +++ .../travel-time/PlanTravelTime.tsx | 3 +- .../chat/messages/OgPlacePreviewCard.tsx | 7 +- src/components/map/MapBookmarkPins.tsx | 39 +- src/components/map/PlanItineraryMapRoutes.tsx | 10 +- .../map/map-bookmark-preview-query.test.ts | 85 +--- .../map/map-bookmark-preview-query.ts | 48 +-- src/components/place/HeroSection.tsx | 6 +- src/hooks/usePlacePhotoUrl.test.ts | 382 ++++++------------ src/hooks/usePlacePhotoUrl.ts | 85 +--- src/hooks/usePlacesSearch.test.ts | 8 +- src/hooks/useRooms.ts | 4 +- src/hooks/useVisiblePlacePhotoUrl.test.tsx | 35 ++ src/hooks/useVisiblePlacePhotoUrl.ts | 14 + src/lib/api/errors.ts | 8 + src/lib/api/places.test.ts | 66 +++ src/lib/api/places.ts | 14 +- src/lib/place-photo-query.ts | 79 ++-- src/lib/places/place-batch-cache.ts | 253 +----------- src/lib/places/place-batch-loader.test.ts | 206 ++++++++++ src/lib/places/place-batch-loader.ts | 100 +++++ src/lib/places/place-photo-refresh.test.ts | 67 +++ src/lib/places/place-photo-refresh.ts | 10 +- src/lib/places/place-queries.test.ts | 101 +++++ src/lib/places/place-queries.ts | 80 ++-- .../places/warmChatHistoryPlacePhotos.test.ts | 14 +- src/lib/plan/schedule-bulk-hydration.test.ts | 29 ++ src/lib/plan/schedule-bulk-hydration.ts | 41 +- .../plan/schedule-preview-hydration.test.ts | 201 +++++++++ src/lib/plan/scheduleItemPlaces.ts | 21 +- .../scheduleItemRoutePersistedQuery.test.ts | 31 +- .../plan/scheduleItemRoutePersistedQuery.ts | 16 + src/lib/query-client.test.ts | 19 + src/lib/query-client.ts | 9 +- src/lib/rooms.ts | 34 +- 39 files changed, 1373 insertions(+), 859 deletions(-) create mode 100644 src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx create mode 100644 src/hooks/useVisiblePlacePhotoUrl.test.tsx create mode 100644 src/hooks/useVisiblePlacePhotoUrl.ts create mode 100644 src/lib/places/place-batch-loader.test.ts create mode 100644 src/lib/places/place-batch-loader.ts create mode 100644 src/lib/places/place-queries.test.ts create mode 100644 src/lib/plan/schedule-preview-hydration.test.ts diff --git a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx index ced23d46..fc49ceee 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx @@ -4,6 +4,8 @@ import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vitest"; import { BookmarkFolderDetailView } from "./BookmarkFolderDetailView"; +const placeQueries = vi.hoisted(() => ({ current: [] as unknown[] })); + vi.mock("@/hooks/useOpenPlaceOnMap", () => ({ useOpenPlaceOnMap: () => vi.fn() })); vi.mock("@/stores/session-store", () => ({ useSessionStore: () => "room" })); vi.mock("@/hooks/useRooms", () => ({ @@ -11,21 +13,40 @@ vi.mock("@/hooks/useRooms", () => ({ useRoomBookmarks: () => ({ data: [{ bookmarkId: 1, googlePlaceId: "place" }], isPending: false }), })); vi.mock("@/lib/places/place-queries", () => ({ placePreviewQueryOptions: () => ({ queryKey: ["place"] }) })); -vi.mock("@tanstack/react-query", () => ({ - useQueries: () => [{ isPending: false, isFetching: true, data: { name: "저장한 장소", googlePlaceId: "place", formattedAddress: "서울" } }], -})); +vi.mock("@tanstack/react-query", () => ({ useQueries: () => placeQueries.current })); vi.mock("./BookmarkFolderDetailHeader", () => ({ BookmarkFolderDetailHeader: () => null })); vi.mock("./BookmarkPlaceRow", () => ({ BookmarkPlaceRow: ({ place }: { place: { name: string } }) =>
{place.name}
})); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -it("keeps cached places visible while their previews refetch", async () => { +async function renderFolder() { const container = document.createElement("div"); const root = createRoot(container); + await act(async () => root.render()); + return { container, unmount: () => act(async () => root.unmount()) }; +} + +it("keeps cached places visible while their previews refetch", async () => { + placeQueries.current = [ + { isPending: false, isFetching: true, data: { name: "저장한 장소", googlePlaceId: "place", formattedAddress: "서울" } }, + ]; + const { container, unmount } = await renderFolder(); try { - await act(async () => root.render()); expect(container.textContent).toContain("저장한 장소"); expect(container.querySelector('[role="status"]')).toBeNull(); } finally { - await act(async () => root.unmount()); + await unmount(); + } +}); + +it("미리보기를 모두 받지 못하면 내부 에러 문구 대신 안내 문구를 보여 준다", async () => { + placeQueries.current = [ + { isPending: false, isFetching: false, isError: true, error: new Error("batch 응답에 place가 없음") }, + ]; + const { container, unmount } = await renderFolder(); + try { + expect(container.textContent).toContain("장소 정보를 불러오지 못했습니다."); + expect(container.textContent).not.toContain("batch 응답에"); + } finally { + await unmount(); } }); diff --git a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx index 60fa1614..d1076e01 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx @@ -98,8 +98,6 @@ export function BookmarkFolderDetailView({ folder }: { folder: BookmarkFolder }) placeQueries.length > 0 && placeQueries.every((q) => q.isError); - const firstPlaceError = placeQueries.find((q) => q.isError)?.error; - const retryPlacePreviews = () => { for (const q of placeQueries) { void q.refetch(); @@ -166,10 +164,9 @@ export function BookmarkFolderDetailView({ folder }: { folder: BookmarkFolder }) ) : cardsError ? (
+ {/* 에러 문구는 내부 메시지(요청 id·API 오류 코드)라 화면에 그대로 보여 주지 않는다 */}

- {firstPlaceError instanceof Error - ? firstPlaceError.message - : "장소 정보를 불러오지 못했습니다."} + 장소 정보를 불러오지 못했습니다.

+ ); +} + +it("못 불러온 주소는 숨기고, 주소가 바뀌면 다시 보인다", async () => { + const container = document.createElement("div"); + const root = createRoot(container); + try { + await act(async () => root.render()); + expect(container.textContent).toBe("https://cdn.example/broken.jpg"); + + await act(async () => container.querySelector("button")?.click()); + expect(container.textContent).toBe("사진 없음"); + + await act(async () => root.render()); + expect(container.textContent).toBe("https://cdn.example/fresh.jpg"); + } finally { + await act(async () => root.unmount()); + } +}); diff --git a/src/hooks/useVisiblePlacePhotoUrl.ts b/src/hooks/useVisiblePlacePhotoUrl.ts new file mode 100644 index 00000000..2094b3ab --- /dev/null +++ b/src/hooks/useVisiblePlacePhotoUrl.ts @@ -0,0 +1,14 @@ +import { useState } from "react"; + +/** + * 다시 받아도 불러오지 못한 사진 주소는 그리지 않는다 — 깨진 이미지 대신 각 카드의 '사진 없음' 모양을 보인다. + * 주소가 바뀌면(다시 받은 주소가 다르면) 다시 그린다. + */ +export function useVisiblePlacePhotoUrl(url: string | null | undefined) { + const [unavailableUrl, setUnavailableUrl] = useState(null); + const visibleUrl = url && url !== unavailableUrl ? url : null; + return { + visibleUrl, + markUnavailable: () => setUnavailableUrl(visibleUrl), + }; +} diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts index 1f36b0fd..843b1143 100644 --- a/src/lib/api/errors.ts +++ b/src/lib/api/errors.ts @@ -234,6 +234,14 @@ export class HttpError extends Error { } } +/** + * 서버 응답을 받지 못한 일시적 네트워크 오류인지 — fetch는 이 실패를 TypeError로 던진다. + * 429·4xx·5xx 응답은 다시 보내도 같은 결과라 여기에 넣지 않는다. + */ +export function isNetworkError(error: unknown): boolean { + return error instanceof TypeError; +} + /** 방 생성·수정 API의 여행지 관련 서버 에러(HTTP 4xx) → 한국어 사용자 메시지 */ export function messageForRoomDestinationError( body: unknown, diff --git a/src/lib/api/places.test.ts b/src/lib/api/places.test.ts index 86f2c86e..152a3039 100644 --- a/src/lib/api/places.test.ts +++ b/src/lib/api/places.test.ts @@ -71,6 +71,38 @@ describe("place photo API requests", () => { ); }); + it("throws an HttpError carrying the status when a single photo request fails", async () => { + const { requestPlacePhotoUrl } = await import("@/lib/api/places"); + const { HttpError } = await import("@/lib/api/errors"); + apiFetch.mockResolvedValueOnce({ + status: 502, + ok: false, + json: async () => ({ code: "EXTERNAL_API_ERROR", message: "외부 API 호출 중 오류가 발생했습니다" }), + }); + + const error = await requestPlacePhotoUrl("ChIJ-place-1").catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ status: 502, message: "외부 API 호출 중 오류가 발생했습니다" }); + }); + + it("throws an HttpError when the whole photo batch request is rate limited", async () => { + const { requestPlacePhotoUrlsBatch } = await import("@/lib/api/places"); + const { HttpError } = await import("@/lib/api/errors"); + apiFetch.mockResolvedValueOnce({ + status: 429, + ok: false, + json: async () => ({ code: "RATE_LIMIT_EXCEEDED", message: "요청이 너무 많습니다" }), + }); + + const error = await requestPlacePhotoUrlsBatch(["ChIJ-place-1", "ChIJ-place-2"]).catch( + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ status: 429 }); + }); + it("requests batch photo URLs with googlePlaceIds and reads googlePlaceId response items", async () => { const { requestPlacePhotoUrlsBatch } = await import("@/lib/api/places"); apiFetch.mockResolvedValueOnce({ @@ -149,3 +181,37 @@ describe("place photo API requests", () => { ]); }); }); + +describe("place preview API requests", () => { + beforeEach(() => { + apiFetch.mockReset(); + }); + + it("throws an HttpError when the preview batch request is rate limited", async () => { + const { requestPlacePreviewsBatch } = await import("@/lib/api/places"); + const { HttpError } = await import("@/lib/api/errors"); + apiFetch.mockResolvedValueOnce({ + status: 429, + ok: false, + json: async () => ({ code: "RATE_LIMIT_EXCEEDED", message: "요청이 너무 많습니다" }), + }); + + const error = await requestPlacePreviewsBatch(["ChIJ-place-1", "ChIJ-place-2"]).catch( + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ status: 429, message: "요청이 너무 많습니다" }); + }); + + it("throws an HttpError carrying the status when a single preview request fails", async () => { + const { requestPlacePreview } = await import("@/lib/api/places"); + const { HttpError } = await import("@/lib/api/errors"); + apiFetch.mockResolvedValueOnce({ status: 502, ok: false, json: async () => ({}) }); + + const error = await requestPlacePreview("ChIJ-place-1").catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ status: 502 }); + }); +}); diff --git a/src/lib/api/places.ts b/src/lib/api/places.ts index 0a43c89d..a8d16215 100644 --- a/src/lib/api/places.ts +++ b/src/lib/api/places.ts @@ -1,7 +1,7 @@ import { API_BASE } from "./config"; import { apiFetch } from "./client"; import { chunkArray, PLACE_BATCH_MAX_SIZE } from "./batch-chunk"; -import { readUserFacingMessageFromApiBody } from "./errors"; +import { HttpError, readUserFacingMessageFromApiBody } from "./errors"; import { apiUrl, jsonBody, requestJson, tryParseJson } from "./http"; // ─── Response types ──────────────────────────────────────────────────────── @@ -156,7 +156,7 @@ export async function searchPlaces(params: { } const res = await apiFetch(url.toString()); - if (!res.ok) throw new Error(`Places search failed: ${res.status}`); + if (!res.ok) throw new HttpError(res.status, `Places search failed: ${res.status}`); return res.json(); } @@ -166,7 +166,7 @@ export async function requestPlaceDetail( const res = await apiFetch( `${API_BASE}/places/${encodeURIComponent(googlePlaceId)}`, ); - if (!res.ok) throw new Error(`Place detail failed: ${res.status}`); + if (!res.ok) throw new HttpError(res.status, `Place detail failed: ${res.status}`); return res.json(); } @@ -176,7 +176,7 @@ export async function requestPlacePreview( const res = await apiFetch( `${API_BASE}/places/${encodeURIComponent(googlePlaceId)}/preview`, ); - if (!res.ok) throw new Error(`Place preview failed: ${res.status}`); + if (!res.ok) throw new HttpError(res.status, `Place preview failed: ${res.status}`); return res.json(); } @@ -196,7 +196,7 @@ export async function requestPlacePhotoUrl( if (!res.ok) { const body = await tryParseJson(res); const detail = readUserFacingMessageFromApiBody(body); - throw new Error(detail ?? `Place photo failed: ${res.status}`); + throw new HttpError(res.status, detail ?? `Place photo failed: ${res.status}`); } const data: PlacePhotoResponse = await res.json(); return typeof data.photoUrl === "string" ? data.photoUrl : ""; @@ -208,7 +208,7 @@ async function requestPlacePreviewsBatchChunk( const data = await requestJson( apiUrl("/places/previews/batch"), { method: "POST", ...jsonBody({ googlePlaceIds }) }, - { errorMessage: "장소 미리보기 일괄 조회 실패" }, + { errorMessage: "장소 미리보기 일괄 조회 실패", useHttpError: true }, ); return readBatchResponseList(data, "previews"); } @@ -254,7 +254,7 @@ async function requestPlacePhotoUrlsBatchChunk( if (!res.ok) { const body = await tryParseJson(res); const detail = readUserFacingMessageFromApiBody(body); - throw new Error(detail ?? `장소 사진 URL 일괄 조회 실패: ${res.status}`); + throw new HttpError(res.status, detail ?? `장소 사진 URL 일괄 조회 실패: ${res.status}`); } const data = (await res.json()) as PlacePhotoUrlBatchResponse; return readBatchResponseList(data, "photos"); diff --git a/src/lib/place-photo-query.ts b/src/lib/place-photo-query.ts index 299a0596..38dff823 100644 --- a/src/lib/place-photo-query.ts +++ b/src/lib/place-photo-query.ts @@ -1,13 +1,23 @@ import type { Query } from "@tanstack/react-query"; -import { requestPlacePhotoUrl } from "@/lib/api/places"; -import { getQueryClient } from "@/lib/query-client"; +import { isBatchItemOk } from "@/lib/api/batch-types"; +import { HttpError } from "@/lib/api/errors"; +import { + requestPlacePhotoUrl, + requestPlacePhotoUrlsBatch, + type PlacePhotoUrlBatchItem, +} from "@/lib/api/places"; +import { normalizeGooglePlaceResourceId } from "@/lib/maps"; +import { + createPlaceBatchLoader, + type PlaceBatchResult, +} from "@/lib/places/place-batch-loader"; /** * `usePlacePhotoUrlQuery` — 같은 `googlePlaceId`에 대해 불필요한 `GET /places/photos` 재호출 방지. */ -export const PLACE_PHOTO_QUERY_GC_MS = 7 * 24 * 60 * 60 * 1000; -export const PLACE_PHOTO_EMPTY_STALE_MS = 5 * 60 * 1000; +const PLACE_PHOTO_QUERY_GC_MS = 7 * 24 * 60 * 60 * 1000; +const PLACE_PHOTO_EMPTY_STALE_MS = 5 * 60 * 1000; export function placePhotoUrlQueryKey(googlePlaceId: string) { const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; @@ -34,35 +44,42 @@ export const placePhotoUrlQueryDefaults = { refetchOnMount: true, refetchOnWindowFocus: false, refetchOnReconnect: false, - retry: 2, } as const; -/** batch 시딩 후 구독 — 항목별 실패로 캐시 miss이면 단건 GET fallback */ -export function seededPlacePhotoUrlQueryOptions( - googlePlaceId: string, - options?: { enabled?: boolean }, -) { - const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; - const enabled = (options?.enabled ?? true) && id.length > 0; +function photoBatchItemResult(item: PlacePhotoUrlBatchItem): PlaceBatchResult { + if (isBatchItemOk(item)) { + return { + ok: true, + value: typeof item.photoUrl === "string" ? item.photoUrl.trim() : "", + }; + } + // 항목 실패는 외부 API 실패(EXTERNAL_API_ERROR)일 때만 온다 — 단건으로 보냈으면 502였을 실패다 return { - queryKey: placePhotoUrlQueryKey(id), - queryFn: (): string | Promise => { - const state = getQueryClient()?.getQueryState( - placePhotoUrlQueryKey(id), - ); - const cached = state?.data; - if (typeof cached === "string") { - const trimmed = cached.trim(); - if ( - trimmed.length > 0 || - Date.now() - (state?.dataUpdatedAt ?? 0) < PLACE_PHOTO_EMPTY_STALE_MS - ) { - return trimmed; - } - } - return requestPlacePhotoUrl(id); - }, - enabled, - ...placePhotoUrlQueryDefaults, + ok: false, + error: new HttpError(502, `장소 사진 URL 조회 실패: ${item.errorCode ?? item.status}`), }; } + +/** 응답 id는 요청과 형식(`places/` 접두)이 다르거나 비어 있을 수 있어 같은 기준으로 맞춘다 */ +function placeKey(googlePlaceId: unknown): string { + return typeof googlePlaceId === "string" + ? normalizeGooglePlaceResourceId(googlePlaceId.trim()) + : ""; +} + +/** 같은 커밋에 보이는 카드 사진을 모아 받는다 — 1장이면 단건, 2장 이상이면 batch */ +export const loadPlacePhotoUrl = createPlaceBatchLoader({ + fetchOne: (id) => requestPlacePhotoUrl(id), + fetchMany: async (ids) => { + const byPlace = new Map>(); + for (const item of await requestPlacePhotoUrlsBatch(ids)) { + byPlace.set(placeKey(item.googlePlaceId), photoBatchItemResult(item)); + } + const results = new Map>(); + for (const id of ids) { + const result = byPlace.get(placeKey(id)); + if (result) results.set(id, result); + } + return results; + }, +}); diff --git a/src/lib/places/place-batch-cache.ts b/src/lib/places/place-batch-cache.ts index 768826d7..2becad90 100644 --- a/src/lib/places/place-batch-cache.ts +++ b/src/lib/places/place-batch-cache.ts @@ -1,24 +1,10 @@ import type { QueryClient } from "@tanstack/react-query"; -import type { - PlacePhotoUrlBatchItem, - PlacePreview, - PlacePreviewBatchItem, -} from "@/lib/api/places"; +import type { PlacePreview } from "@/lib/api/places"; import { - requestPlacePhotoUrlsBatch, - requestPlacePreviewsBatch, -} from "@/lib/api/places"; -import { isBatchItemError, isBatchItemOk } from "@/lib/api/batch-types"; -import { - PLACE_PHOTO_EMPTY_STALE_MS, - placePhotoUrlQueryDefaults, - placePhotoUrlQueryKey, -} from "@/lib/place-photo-query"; -import { - normalizePlacePreview, placePreviewQueryDefaults, placePreviewQueryKey, + placePreviewQueryOptions, resolvePreviewGooglePlaceId, } from "@/lib/places/place-queries"; import type { PlanPlace } from "@/lib/plan/types"; @@ -27,67 +13,6 @@ import { getQueryClient } from "@/lib/query-client"; const PLAN_PLACE_PREVIEW_ERROR_TITLE = "장소 정보를 불러올 수 없음"; -/** 동일 googlePlaceId에 대한 in-flight batch 요청 공유 */ -const inFlightPreviewSeedByPlaceId = new Map>(); -const inFlightPhotoSeedByPlaceId = new Map>(); - -function isUsablePreviewBatchItem(item: PlacePreviewBatchItem): boolean { - if (isBatchItemError(item)) return false; - if (isBatchItemOk(item)) return true; - - const record = item as { - googlePlaceId?: unknown; - name?: unknown; - status?: unknown; - }; - if (record.status === "ERROR") return false; - - const googlePlaceId = - typeof record.googlePlaceId === "string" ? record.googlePlaceId.trim() : ""; - const name = typeof record.name === "string" ? record.name.trim() : ""; - return googlePlaceId.length > 0 && name.length > 0; -} - -function previewBatchItemToNormalized( - item: PlacePreviewBatchItem, -): ReturnType | null { - if (!isUsablePreviewBatchItem(item)) return null; - - const record = item as { - googlePlaceId: string; - name: string; - formattedAddress?: string; - location?: { latitude: number; longitude: number } | null; - primaryType?: string; - primaryTypeDisplayName?: string; - }; - - return normalizePlacePreview({ - googlePlaceId: record.googlePlaceId, - name: record.name, - formattedAddress: record.formattedAddress ?? "", - location: record.location ?? null, - primaryType: record.primaryType ?? "", - primaryTypeDisplayName: record.primaryTypeDisplayName ?? "", - }); -} - -export function seedPlacePreviewCaches( - queryClient: QueryClient, - items: readonly PlacePreviewBatchItem[], -): void { - for (const item of items) { - const preview = previewBatchItemToNormalized(item); - if (!preview) continue; - const id = resolvePreviewGooglePlaceId(preview.googlePlaceId); - if (!id.length) continue; - queryClient.setQueryData(placePreviewQueryKey(id), preview, { - updatedAt: Date.now(), - }); - } - reconcileSchedulePlanPlacesAfterPreviewSeed(queryClient); -} - function readCachedPreviewForReconcile( queryClient: QueryClient, googlePlaceId: string, @@ -156,22 +81,6 @@ export function reconcileSchedulePlanPlacesAfterPreviewSeed( } } -export function seedPlacePhotoUrlCaches( - queryClient: QueryClient, - items: readonly PlacePhotoUrlBatchItem[], -): void { - for (const item of items) { - if (!isBatchItemOk(item)) continue; - const id = - typeof item.googlePlaceId === "string" ? item.googlePlaceId.trim() : ""; - const url = typeof item.photoUrl === "string" ? item.photoUrl.trim() : ""; - if (!id.length) continue; - queryClient.setQueryData(placePhotoUrlQueryKey(id), url, { - updatedAt: Date.now(), - }); - } -} - function uncachedPreviewIds( queryClient: QueryClient, googlePlaceIds: readonly string[], @@ -187,55 +96,10 @@ function uncachedPreviewIds( return [...new Set(out)]; } -function uncachedPhotoPlaceIds( - queryClient: QueryClient, - googlePlaceIds: readonly string[], -): string[] { - const out: string[] = []; - for (const raw of googlePlaceIds) { - const id = typeof raw === "string" ? raw.trim() : ""; - if (!id.length) continue; - const state = queryClient.getQueryState(placePhotoUrlQueryKey(id)); - const cached = state?.data; - if (typeof cached === "string" && cached.trim().length > 0) continue; - if ( - typeof cached === "string" && - Date.now() - (state?.dataUpdatedAt ?? 0) < PLACE_PHOTO_EMPTY_STALE_MS - ) { - continue; - } - out.push(id); - } - return [...new Set(out)]; -} - -async function fetchAndSeedUncachedPlacePreviews( - missing: readonly string[], - queryClient: QueryClient, -): Promise { - const results = await requestPlacePreviewsBatch(missing); - seedPlacePreviewCaches(queryClient, results); - - const byNormalizedId = new Map>(); - for (const item of results) { - const preview = previewBatchItemToNormalized(item); - if (!preview) continue; - const id = resolvePreviewGooglePlaceId(preview.googlePlaceId); - if (!id.length) continue; - byNormalizedId.set(id, preview); - } - - for (const requestedId of missing) { - const id = resolvePreviewGooglePlaceId(requestedId); - if (!id.length) continue; - const preview = byNormalizedId.get(id); - if (!preview) continue; - queryClient.setQueryData(placePreviewQueryKey(id), preview, { - updatedAt: Date.now(), - }); - } -} - +/** + * 캐시에 없는 장소 미리보기를 batch로 받아 캐시에 채운다(실패한 곳만 batch로 한 번 더). + * 그래도 실패한 곳은 던지지 않고 비워 둔다 — 일정에는 자리표시자로 보이고, 다음 진입 때 다시 받는다. + */ export async function fetchAndSeedPlacePreviews( googlePlaceIds: readonly string[], queryClient?: QueryClient | null, @@ -246,107 +110,8 @@ export async function fetchAndSeedPlacePreviews( const missing = uncachedPreviewIds(qc, googlePlaceIds); if (!missing.length) return; - const toFetch: string[] = []; - const waits: Promise[] = []; - - for (const id of missing) { - const inFlight = inFlightPreviewSeedByPlaceId.get(id); - if (inFlight) { - waits.push(inFlight); - continue; - } - if (!toFetch.includes(id)) { - toFetch.push(id); - } - } - - let fetchPromise: Promise | null = null; - if (toFetch.length) { - fetchPromise = (async () => { - try { - await fetchAndSeedUncachedPlacePreviews(toFetch, qc); - } finally { - for (const id of toFetch) { - inFlightPreviewSeedByPlaceId.delete(id); - } - } - })(); - for (const id of toFetch) { - inFlightPreviewSeedByPlaceId.set(id, fetchPromise); - } - } - - const pending = [ - ...(fetchPromise ? [fetchPromise] : []), - ...waits.filter((promise) => promise !== fetchPromise), - ]; - if (pending.length) { - await Promise.all(pending); - } + await Promise.allSettled(missing.map((id) => qc.fetchQuery(placePreviewQueryOptions(id)))); + reconcileSchedulePlanPlacesAfterPreviewSeed(qc); } -export async function fetchAndSeedPlacePhotoUrls( - googlePlaceIds: readonly string[], - queryClient?: QueryClient | null, -): Promise { - const qc = queryClient ?? getQueryClient(); - if (!qc) return; - - const missing = uncachedPhotoPlaceIds(qc, googlePlaceIds); - if (!missing.length) return; - - const toFetch: string[] = []; - const waits: Promise[] = []; - - for (const id of missing) { - const inFlight = inFlightPhotoSeedByPlaceId.get(id); - if (inFlight) { - waits.push(inFlight); - continue; - } - if (!toFetch.includes(id)) { - toFetch.push(id); - } - } - - let fetchPromise: Promise | null = null; - if (toFetch.length) { - fetchPromise = (async () => { - try { - const results = await requestPlacePhotoUrlsBatch(toFetch); - seedPlacePhotoUrlCaches(qc, results); - } finally { - for (const id of toFetch) { - inFlightPhotoSeedByPlaceId.delete(id); - } - } - })(); - for (const id of toFetch) { - inFlightPhotoSeedByPlaceId.set(id, fetchPromise); - } - } - - const pending = [ - ...(fetchPromise ? [fetchPromise] : []), - ...waits.filter((promise) => promise !== fetchPromise), - ]; - if (pending.length) { - await Promise.all(pending); - } -} - -/** 미캐시·만료된 장소 사진 URL을 batch로 시딩 */ -export async function ensurePlacePhotoUrlsCached( - googlePlaceIds: readonly string[], - queryClient?: QueryClient | null, -): Promise { - const qc = queryClient ?? getQueryClient(); - if (!qc) return; - - await fetchAndSeedPlacePhotoUrls(googlePlaceIds, qc); -} - -export { - placePhotoUrlQueryDefaults, - placePreviewQueryDefaults, -}; +export { placePreviewQueryDefaults }; diff --git a/src/lib/places/place-batch-loader.test.ts b/src/lib/places/place-batch-loader.test.ts new file mode 100644 index 00000000..415099bb --- /dev/null +++ b/src/lib/places/place-batch-loader.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from "vitest"; + +import { PLACE_BATCH_MAX_SIZE } from "@/lib/api/batch-chunk"; +import { createPlaceBatchLoader, type PlaceBatchResult } from "@/lib/places/place-batch-loader"; + +type Outcome = "ok" | "failed" | "missing"; + +/** id마다 응답 결과를 정한 batch 응답을 만든다 */ +function batchResponse(ids: string[], outcomeOf: (id: string) => Outcome) { + const results = new Map>(); + for (const id of ids) { + const outcome = outcomeOf(id); + if (outcome === "ok") results.set(id, { ok: true, value: `many:${id}` }); + if (outcome === "failed") results.set(id, { ok: false, error: new Error(`failed:${id}`) }); + } + return results; +} + +function createLoader(outcomeOf: (id: string, call: number) => Outcome = () => "ok") { + let call = 0; + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const fetchMany = vi.fn(async (ids: string[]) => { + call += 1; + return batchResponse(ids, (id) => outcomeOf(id, call)); + }); + return { fetchOne, fetchMany, load: createPlaceBatchLoader({ fetchOne, fetchMany }) }; +} + +describe("createPlaceBatchLoader", () => { + it("같은 태스크에서 들어온 id는 batch 하나로 보내고 같은 id는 한 번만 싣는다", async () => { + const { fetchOne, fetchMany, load } = createLoader(); + + await expect(Promise.all([load("a"), load("b"), load("a")])).resolves.toEqual([ + "many:a", + "many:b", + "many:a", + ]); + expect(fetchMany).toHaveBeenCalledExactlyOnceWith(["a", "b"]); + expect(fetchOne).not.toHaveBeenCalled(); + }); + + it("한 건만 들어오면 단건 API로 보낸다", async () => { + const { fetchOne, fetchMany, load } = createLoader(); + + await expect(load("a")).resolves.toBe("one:a"); + expect(fetchOne).toHaveBeenCalledExactlyOnceWith("a"); + expect(fetchMany).not.toHaveBeenCalled(); + }); + + it("다른 태스크에서 들어온 요청은 따로 보낸다", async () => { + const { fetchOne, load } = createLoader(); + + await load("a"); + await load("b"); + expect(fetchOne.mock.calls).toEqual([["a"], ["b"]]); + }); + + it("실패한 항목이 여러 개면 그 id만 batch로 한 번 더 보낸다", async () => { + const { fetchOne, fetchMany, load } = createLoader((id, call) => + call === 1 && id !== "a" ? "failed" : "ok", + ); + + await expect(Promise.all([load("a"), load("b"), load("c")])).resolves.toEqual([ + "many:a", + "many:b", + "many:c", + ]); + expect(fetchMany.mock.calls).toEqual([[["a", "b", "c"]], [["b", "c"]]]); + expect(fetchOne).not.toHaveBeenCalled(); + }); + + it("실패한 항목이 하나면 단건 API로 한 번 더 보낸다", async () => { + const { fetchOne, fetchMany, load } = createLoader((id) => (id === "b" ? "failed" : "ok")); + + await expect(Promise.all([load("a"), load("b")])).resolves.toEqual(["many:a", "one:b"]); + expect(fetchMany).toHaveBeenCalledTimes(1); + expect(fetchOne).toHaveBeenCalledExactlyOnceWith("b"); + }); + + it("다시 보낸 요청에서도 실패하면 그 id는 실패로 끝내고 더 보내지 않는다", async () => { + const { fetchOne, fetchMany, load } = createLoader((id) => (id === "a" ? "ok" : "failed")); + + const settled = await Promise.allSettled([load("a"), load("b"), load("c")]); + + expect(settled.map((result) => result.status)).toEqual(["fulfilled", "rejected", "rejected"]); + expect(fetchMany).toHaveBeenCalledTimes(2); + expect(fetchOne).not.toHaveBeenCalled(); + }); + + it("batch 응답에서 빠진 id도 실패처럼 한 번 더 받는다", async () => { + const { fetchMany, load } = createLoader((id, call) => + call === 1 && id !== "a" ? "missing" : "ok", + ); + + await expect(Promise.all([load("a"), load("b"), load("c")])).resolves.toEqual([ + "many:a", + "many:b", + "many:c", + ]); + expect(fetchMany.mock.calls).toEqual([[["a", "b", "c"]], [["b", "c"]]]); + }); + + it("첫 요청 자체가 실패하면 모은 id 모두 같은 에러로 실패하고 다시 보내지 않는다", async () => { + const failure = new Error("batch failed"); + const fetchMany = vi.fn(async () => { + throw failure; + }); + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const load = createPlaceBatchLoader({ fetchOne, fetchMany }); + + const settled = await Promise.allSettled([load("a"), load("b")]); + + expect(settled).toEqual([ + { status: "rejected", reason: failure }, + { status: "rejected", reason: failure }, + ]); + expect(fetchMany).toHaveBeenCalledTimes(1); + expect(fetchOne).not.toHaveBeenCalled(); + }); + + it("다시 보낸 요청 자체가 실패하면 실패한 id만 그 에러로 끝난다", async () => { + const failure = new Error("retry failed"); + const fetchMany = vi + .fn() + .mockResolvedValueOnce(batchResponse(["a", "b", "c"], (id) => (id === "a" ? "ok" : "failed"))) + .mockRejectedValueOnce(failure); + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const load = createPlaceBatchLoader({ fetchOne, fetchMany }); + + const settled = await Promise.allSettled([load("a"), load("b"), load("c")]); + + expect(settled).toEqual([ + { status: "fulfilled", value: "many:a" }, + { status: "rejected", reason: failure }, + { status: "rejected", reason: failure }, + ]); + }); + + describe("서버 한도를 넘으면", () => { + const ids = Array.from({ length: PLACE_BATCH_MAX_SIZE + 20 }, (_, index) => `p${index}`); + const head = ids.slice(0, PLACE_BATCH_MAX_SIZE); + const tail = ids.slice(PLACE_BATCH_MAX_SIZE); + const fulfilled = (id: string) => ({ status: "fulfilled", value: `many:${id}` }); + + it("나눠 차례로 보내고, 뒤 묶음 요청이 실패해도 앞 묶음에서 받은 값은 쓴다", async () => { + const failure = new Error("rate limited"); + const fetchMany = vi + .fn() + .mockResolvedValueOnce(batchResponse(head, () => "ok")) + .mockRejectedValueOnce(failure); + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const load = createPlaceBatchLoader({ fetchOne, fetchMany }); + + const settled = await Promise.allSettled(ids.map((id) => load(id))); + + expect(fetchMany.mock.calls).toEqual([[head], [tail]]); + expect(settled).toEqual([ + ...head.map(fulfilled), + ...tail.map(() => ({ status: "rejected", reason: failure })), + ]); + }); + + it("앞 묶음 요청이 실패하면 남은 묶음은 보내지 않고 모두 같은 에러로 실패한다", async () => { + const failure = new Error("rate limited"); + const fetchMany = vi.fn().mockRejectedValueOnce(failure); + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const load = createPlaceBatchLoader({ fetchOne, fetchMany }); + + const settled = await Promise.allSettled(ids.map((id) => load(id))); + + expect(fetchMany).toHaveBeenCalledTimes(1); + expect(settled).toEqual(ids.map(() => ({ status: "rejected", reason: failure }))); + }); + + it("묶음마다 실패한 항목을 모아 한 번에 다시 보낸다", async () => { + const { fetchMany, load } = createLoader((id, call) => + call <= 2 && (id === head[0] || id === tail[0]) ? "failed" : "ok", + ); + + const settled = await Promise.allSettled(ids.map((id) => load(id))); + + expect(fetchMany.mock.calls).toEqual([[head], [tail], [[head[0], tail[0]]]]); + expect(settled).toEqual(ids.map(fulfilled)); + }); + + it("요청이 실패한 묶음이 있으면 앞 묶음의 실패 항목도 다시 보내지 않는다", async () => { + const failure = new Error("rate limited"); + const fetchMany = vi + .fn() + .mockResolvedValueOnce(batchResponse(head, (id) => (id === head[0] ? "failed" : "ok"))) + .mockRejectedValueOnce(failure); + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const load = createPlaceBatchLoader({ fetchOne, fetchMany }); + + const settled = await Promise.allSettled(ids.map((id) => load(id))); + + expect(fetchMany).toHaveBeenCalledTimes(2); + expect(fetchOne).not.toHaveBeenCalled(); + expect(settled).toEqual([ + { status: "rejected", reason: new Error(`failed:${head[0]}`) }, + ...head.slice(1).map(fulfilled), + ...tail.map(() => ({ status: "rejected", reason: failure })), + ]); + }); + }); +}); diff --git a/src/lib/places/place-batch-loader.ts b/src/lib/places/place-batch-loader.ts new file mode 100644 index 00000000..29f55f58 --- /dev/null +++ b/src/lib/places/place-batch-loader.ts @@ -0,0 +1,100 @@ +import { chunkArray, PLACE_BATCH_MAX_SIZE } from "@/lib/api/batch-chunk"; + +/** batch 응답 항목 하나 — 받은 값, 또는 그 항목만의 실패 */ +export type PlaceBatchResult = + | { ok: true; value: T } + | { ok: false; error: unknown }; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +function createDeferred(): Deferred { + let resolve: (value: T) => void = () => {}; + let reject: (error: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** + * 같은 태스크에서 요청된 장소 id를 모아 서버 한도(100곳)씩 차례로 보낸다. 한 번에 1건이면 단건 API, 2건 이상이면 + * batch API를 쓴다 — batch rate limit(사용자당 분당)이 단건보다 작아서 1건짜리 batch는 한도만 먼저 쓴다. + * 항목이 실패하면 실패한 id만 같은 방식으로 한 번 더 보내고, 그래도 실패하면 그 id는 실패로 끝낸다. + * 요청 자체가 실패하면 그 묶음과 아직 보내지 않은 id가 그 에러로 실패하고, 실패한 항목도 다시 보내지 않는다 — + * 앞 묶음에서 받은 값은 그대로 쓴다. 다시 보낼지는 TanStack 재시도 규칙이 정한다. + */ +export function createPlaceBatchLoader({ + fetchOne, + fetchMany, +}: { + fetchOne: (id: string) => Promise; + fetchMany: (ids: string[]) => Promise>>; +}): (id: string) => Promise { + const pending = new Map>(); + let queued: string[] = []; + + function settle(id: string, result: PlaceBatchResult) { + const deferred = pending.get(id); + pending.delete(id); + if (!deferred) return; + if (result.ok) deferred.resolve(result.value); + else deferred.reject(result.error); + } + + async function request(ids: string[]): Promise>> { + if (ids.length > 1) return fetchMany(ids); + const [id] = ids; + return new Map>([[id, { ok: true, value: await fetchOne(id) }]]); + } + + /** 받은 값은 바로 끝내고, 항목이 실패했거나 응답에 없던 id는 실패 결과로 돌려준다 */ + async function send(ids: string[]) { + const failed = new Map>(); + const chunks = chunkArray(ids, PLACE_BATCH_MAX_SIZE); + for (const [index, chunk] of chunks.entries()) { + let results: ReadonlyMap>; + try { + results = await request(chunk); + } catch (error) { + for (const id of chunks.slice(index).flat()) settle(id, { ok: false, error }); + return { failed, stopped: true }; + } + for (const id of chunk) { + const result: PlaceBatchResult = results.get(id) ?? { + ok: false, + error: new Error(`batch 응답에 ${id}가 없음`), + }; + if (result.ok) settle(id, result); + else failed.set(id, result); + } + } + return { failed, stopped: false }; + } + + async function flush() { + const ids = queued; + queued = []; + + const first = await send(ids); + // 요청이 실패했으면 실패한 항목도 다시 보내지 않는다 — 실패 직후에 보내면 같은 이유(429·5xx)로 또 실패한다 + const { failed } = first.stopped ? first : await send([...first.failed.keys()]); + for (const [id, result] of failed) settle(id, result); + } + + return (id) => { + const inFlight = pending.get(id); + if (inFlight) return inFlight.promise; + + const deferred = createDeferred(); + pending.set(id, deferred); + // 같은 커밋에서 마운트된 카드들의 요청이 이번 태스크 안에 다 들어온 뒤 한 번에 보낸다 + if (!queued.length) setTimeout(() => void flush(), 0); + queued.push(id); + return deferred.promise; + }; +} diff --git a/src/lib/places/place-photo-refresh.test.ts b/src/lib/places/place-photo-refresh.test.ts index 4e227083..04a7ab6b 100644 --- a/src/lib/places/place-photo-refresh.test.ts +++ b/src/lib/places/place-photo-refresh.test.ts @@ -101,4 +101,71 @@ describe("refreshPlacePhotoUrl", () => { }), ); }); + + describe("onPhotoUnavailable", () => { + function brokenImage(dataset: Record = {}) { + return { + src: "https://example.invalid/broken.jpg", + currentSrc: "https://example.invalid/broken.jpg", + complete: true, + naturalWidth: 0, + naturalHeight: 0, + dataset, + isConnected: true, + } as unknown as HTMLImageElement; + } + + async function failImage( + image: HTMLImageElement, + googlePlaceId: string | null = "ChIJ-place-1", + ) { + const { handlePlacePhotoImageError } = await import( + "@/lib/places/place-photo-refresh" + ); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const onPhotoUnavailable = vi.fn(); + handlePlacePhotoImageError({ + source: "plan-place-card", + googlePlaceId, + placeName: "Spasso", + image, + onPhotoUnavailable, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + return onPhotoUnavailable; + } + + it("is called without another request when the element already tried a refresh", async () => { + const onPhotoUnavailable = await failImage(brokenImage({ photoRefreshAttempted: "true" })); + + expect(onPhotoUnavailable).toHaveBeenCalledOnce(); + expect(requestPlacePhotoUrl).not.toHaveBeenCalled(); + }); + + it("is called right away when there is no place to refresh", async () => { + const onPhotoUnavailable = await failImage(brokenImage(), null); + + expect(onPhotoUnavailable).toHaveBeenCalledOnce(); + expect(requestPlacePhotoUrl).not.toHaveBeenCalled(); + }); + + it("is called when the refresh returns no URL or fails", async () => { + requestPlacePhotoUrl.mockResolvedValueOnce(""); + const afterEmpty = await failImage(brokenImage()); + requestPlacePhotoUrl.mockRejectedValueOnce(new Error("Place photo failed: 500")); + const afterFailure = await failImage(brokenImage()); + + expect(afterEmpty).toHaveBeenCalledOnce(); + expect(afterFailure).toHaveBeenCalledOnce(); + }); + + it("is not called while the refreshed URL loads", async () => { + requestPlacePhotoUrl.mockResolvedValueOnce("https://cdn.example/fresh.jpg"); + const image = brokenImage(); + const onPhotoUnavailable = await failImage(image); + + expect(image.src).toBe("https://cdn.example/fresh.jpg"); + expect(onPhotoUnavailable).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/lib/places/place-photo-refresh.ts b/src/lib/places/place-photo-refresh.ts index cc82884f..49270be3 100644 --- a/src/lib/places/place-photo-refresh.ts +++ b/src/lib/places/place-photo-refresh.ts @@ -57,13 +57,17 @@ export function handlePlacePhotoImageError(args: { googlePlaceId?: string | null; placeName?: string | null; image: HTMLImageElement; + /** 다시 받아도 보여 줄 사진이 없을 때 — 깨진 이미지 대신 사진 없음 상태로 바꾸게 한다 */ + onPhotoUnavailable?: () => void; }): void { logPlacePhotoImageError(args); const id = typeof args.googlePlaceId === "string" ? args.googlePlaceId.trim() : ""; - if (!id.length) return; - if (args.image.dataset.photoRefreshAttempted === "true") return; + if (!id.length || args.image.dataset.photoRefreshAttempted === "true") { + args.onPhotoUnavailable?.(); + return; + } args.image.dataset.photoRefreshAttempted = "true"; void refreshPlacePhotoUrl(id) @@ -78,6 +82,7 @@ export function handlePlacePhotoImageError(args: { placeName: args.placeName ?? null, imageConnected: args.image.isConnected, }); + args.onPhotoUnavailable?.(); }) .catch((error: unknown) => { console.warn("[PHOTO-URL] refresh failed", { @@ -86,5 +91,6 @@ export function handlePlacePhotoImageError(args: { placeName: args.placeName ?? null, ...describeUnknownError(error), }); + args.onPhotoUnavailable?.(); }); } diff --git a/src/lib/places/place-queries.test.ts b/src/lib/places/place-queries.test.ts new file mode 100644 index 00000000..67ba8384 --- /dev/null +++ b/src/lib/places/place-queries.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const requestPlacePreview = vi.fn(); +const requestPlacePreviewsBatch = vi.fn(); + +vi.mock("@/lib/api/places", () => ({ + requestPlaceDetail: vi.fn(), + requestPlacePreview, + requestPlacePreviewsBatch, +})); + +const previewItem = (googlePlaceId: string) => ({ + googlePlaceId, + name: `이름 ${googlePlaceId}`, + formattedAddress: "서울", + location: { latitude: 37.5, longitude: 127.0 }, + primaryType: "cafe", + primaryTypeDisplayName: "카페", +}); +const failed = (googlePlaceId: string) => ({ + status: "FAILED", + googlePlaceId, + errorCode: "EXTERNAL_API_ERROR", +}); + +/** 같은 커밋에 미리보기 쿼리 여러 개가 마운트된 것처럼 한 태스크 안에서 queryFn을 부른다 */ +async function loadPreviews(ids: string[]) { + const { placePreviewQueryOptions } = await import("@/lib/places/place-queries"); + return Promise.allSettled(ids.map((id) => placePreviewQueryOptions(id).queryFn())); +} + +describe("placePreviewQueryOptions", () => { + beforeEach(() => { + requestPlacePreview.mockReset(); + requestPlacePreviewsBatch.mockReset(); + }); + + it("같은 커밋의 미리보기 쿼리는 batch 하나로 받고, 응답 id 형식이 달라도 같은 장소로 맞춘다", async () => { + requestPlacePreviewsBatch.mockResolvedValueOnce([ + { status: "OK", ...previewItem("places/ChIJ-a") }, + { status: "OK", ...previewItem("ChIJ-b") }, + ]); + + const [a, b] = await loadPreviews(["ChIJ-a", "ChIJ-b"]); + + expect(requestPlacePreviewsBatch).toHaveBeenCalledExactlyOnceWith(["ChIJ-a", "ChIJ-b"]); + expect(a).toMatchObject({ + status: "fulfilled", + value: { name: "이름 places/ChIJ-a", location: { lat: 37.5, lng: 127.0 } }, + }); + expect(b).toMatchObject({ status: "fulfilled", value: { name: "이름 ChIJ-b" } }); + expect(requestPlacePreview).not.toHaveBeenCalled(); + }); + + it("status 없이 온 항목도 id와 이름이 있으면 받는다", async () => { + requestPlacePreviewsBatch.mockResolvedValueOnce([previewItem("ChIJ-a"), previewItem("ChIJ-b")]); + + const [a, b] = await loadPreviews(["ChIJ-a", "ChIJ-b"]); + + expect(a).toMatchObject({ status: "fulfilled", value: { name: "이름 ChIJ-a" } }); + expect(b).toMatchObject({ status: "fulfilled", value: { name: "이름 ChIJ-b" } }); + expect(requestPlacePreviewsBatch).toHaveBeenCalledTimes(1); + expect(requestPlacePreview).not.toHaveBeenCalled(); + }); + + it("FAILED 항목은 실패한 곳만 batch로 한 번 더 받고, 그래도 실패하면 502로 끝낸다", async () => { + const { HttpError } = await import("@/lib/api/errors"); + requestPlacePreviewsBatch + .mockResolvedValueOnce([ + { status: "OK", ...previewItem("ChIJ-ok") }, + failed("ChIJ-retried"), + failed("ChIJ-failed"), + ]) + .mockResolvedValueOnce([{ status: "OK", ...previewItem("ChIJ-retried") }, failed("ChIJ-failed")]); + + const [ok, retried, stillFailed] = await loadPreviews(["ChIJ-ok", "ChIJ-retried", "ChIJ-failed"]); + + expect(requestPlacePreviewsBatch.mock.calls).toEqual([ + [["ChIJ-ok", "ChIJ-retried", "ChIJ-failed"]], + [["ChIJ-retried", "ChIJ-failed"]], + ]); + expect(ok.status).toBe("fulfilled"); + expect(retried.status).toBe("fulfilled"); + expect(stillFailed.status).toBe("rejected"); + if (stillFailed.status === "rejected") { + expect(stillFailed.reason).toBeInstanceOf(HttpError); + expect(stillFailed.reason).toMatchObject({ status: 502 }); + } + expect(requestPlacePreview).not.toHaveBeenCalled(); + }); + + it("미리보기 1곳은 단건 API로 받는다", async () => { + requestPlacePreview.mockResolvedValueOnce(previewItem("ChIJ-single")); + + const [single] = await loadPreviews(["places/ChIJ-single"]); + + expect(single).toMatchObject({ status: "fulfilled", value: { googlePlaceId: "ChIJ-single" } }); + expect(requestPlacePreview).toHaveBeenCalledExactlyOnceWith("ChIJ-single"); + expect(requestPlacePreviewsBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/places/place-queries.ts b/src/lib/places/place-queries.ts index 005ff847..f87b051a 100644 --- a/src/lib/places/place-queries.ts +++ b/src/lib/places/place-queries.ts @@ -1,17 +1,20 @@ +import { isBatchItemError, isBatchItemOk } from "@/lib/api/batch-types"; +import { HttpError } from "@/lib/api/errors"; import { requestPlaceDetail, - requestPlacePhotoUrl, requestPlacePreview, + requestPlacePreviewsBatch, type PlaceDetail, type PlacePreview, + type PlacePreviewBatchItem, type PlacePreviewResponse, } from "@/lib/api/places"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; -import { getQueryClient } from "@/lib/query-client"; import { - placePhotoUrlQueryDefaults, - placePhotoUrlQueryKey, -} from "@/lib/place-photo-query"; + createPlaceBatchLoader, + type PlaceBatchResult, +} from "@/lib/places/place-batch-loader"; +import { getQueryClient } from "@/lib/query-client"; export const placeDetailQueryDefaults = { staleTime: Infinity, @@ -104,6 +107,51 @@ export function resolvePreviewGooglePlaceId(googlePlaceId: string): string { return raw.length ? normalizeGooglePlaceResourceId(raw) : ""; } +/** status 없이 오는 응답(레거시 `items`)도 id와 이름이 있으면 받은 것으로 본다 */ +function hasPreviewIdAndName({ + googlePlaceId, + name, +}: { + googlePlaceId?: unknown; + name?: unknown; +}): boolean { + return ( + typeof googlePlaceId === "string" && + googlePlaceId.trim().length > 0 && + typeof name === "string" && + name.trim().length > 0 + ); +} + +function previewBatchItemResult( + item: PlacePreviewBatchItem, +): PlaceBatchResult | null { + if (isBatchItemError(item)) { + // 항목 실패는 외부 API 실패(EXTERNAL_API_ERROR)일 때만 온다 — 단건으로 보냈으면 502였을 실패다 + return { + ok: false, + error: new HttpError(502, `장소 미리보기 조회 실패: ${item.errorCode ?? item.status}`), + }; + } + if (isBatchItemOk(item) || hasPreviewIdAndName(item)) { + return { ok: true, value: normalizePlacePreview(item) }; + } + return null; +} + +/** 같은 커밋에 요청된 미리보기를 모아 받는다 — 1곳이면 단건, 2곳 이상이면 batch */ +const loadPlacePreviewInBatch = createPlaceBatchLoader({ + fetchOne: async (id) => normalizePlacePreview(await requestPlacePreview(id)), + fetchMany: async (ids) => { + const results = new Map>(); + for (const item of await requestPlacePreviewsBatch(ids)) { + const result = previewBatchItemResult(item); + if (result) results.set(resolvePreviewGooglePlaceId(item.googlePlaceId), result); + } + return results; + }, +}); + /** useQueries queryFn 등 중첩 fetchQuery 없이 preview API만 호출 */ export async function loadPlacePreview(googlePlaceId: string): Promise { const id = resolvePreviewGooglePlaceId(googlePlaceId); @@ -116,15 +164,15 @@ export async function loadPlacePreview(googlePlaceId: string): Promise loadPlacePreview(id), + queryFn: () => loadPlacePreviewInBatch(id), enabled: id.length > 0, ...placePreviewQueryDefaults, - retry: 1, }; } @@ -173,21 +221,3 @@ export async function fetchPlacePreview(googlePlaceId: string): Promise { - const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; - if (!id.length) { - throw new Error("fetchPlacePhotoUrl: empty googlePlaceId"); - } - - const queryClient = getQueryClient(); - if (!queryClient) { - return requestPlacePhotoUrl(id); - } - - return queryClient.fetchQuery({ - queryKey: placePhotoUrlQueryKey(id), - queryFn: () => requestPlacePhotoUrl(id), - ...placePhotoUrlQueryDefaults, - }); -} diff --git a/src/lib/places/warmChatHistoryPlacePhotos.test.ts b/src/lib/places/warmChatHistoryPlacePhotos.test.ts index 2b0fe86e..6c668fe8 100644 --- a/src/lib/places/warmChatHistoryPlacePhotos.test.ts +++ b/src/lib/places/warmChatHistoryPlacePhotos.test.ts @@ -1,15 +1,18 @@ import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const fetchAndSeedPlacePhotoUrls = vi.fn(); +const requestPlacePhotoUrl = vi.fn(); +const requestPlacePhotoUrlsBatch = vi.fn(); -vi.mock("@/lib/places/place-batch-cache", () => ({ - fetchAndSeedPlacePhotoUrls, +vi.mock("@/lib/api/places", () => ({ + requestPlacePhotoUrl, + requestPlacePhotoUrlsBatch, })); describe("warmPlacePhotoQueriesFromChatHistory", () => { beforeEach(() => { - fetchAndSeedPlacePhotoUrls.mockReset(); + requestPlacePhotoUrl.mockReset(); + requestPlacePhotoUrlsBatch.mockReset(); }); it("does not warm photo URLs before place cards enter the viewport", async () => { @@ -57,6 +60,7 @@ describe("warmPlacePhotoQueriesFromChatHistory", () => { }, ]); - expect(fetchAndSeedPlacePhotoUrls).not.toHaveBeenCalled(); + expect(requestPlacePhotoUrl).not.toHaveBeenCalled(); + expect(requestPlacePhotoUrlsBatch).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index 78fef7eb..850cbe25 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -194,6 +194,35 @@ describe("hydrateScheduleRoutesBatch", () => { expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(2); }); + it.each(["NO_ROUTE", "LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"])( + "경로 없음 답(%s)은 null로 시딩해 단건으로 다시 묻지 않는다", + async (errorCode) => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const places = [place(1, "places/a"), place(2, "places/b"), place(3, "places/c")]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); + getScheduleItemRoutesBatchMock.mockResolvedValue([ + routeResult, + { status: "FAILED", itemId: 2, travelMode: "WALKING", errorCode }, + ]); + + await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + expect( + queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "WALKING")), + ).toBeNull(); + await expect( + resolveScheduleSegmentRoute({ + roomId: ROOM_ID, + scheduleId: SCHEDULE_ID, + segmentSourceItemId: 2, + travelMode: "WALKING", + }), + ).resolves.toBeNull(); + expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + }, + ); + it("응답 대기 중 일정이 바뀌면(지문 불일치) 늦게 도착한 결과를 버린다", async () => { const queryClient = new QueryClient(); queryClient.setQueryData( diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index d51c2aee..a2b3ac82 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -13,7 +13,6 @@ import { } from "@/lib/places/place-batch-cache"; import type { PlacePreview } from "@/lib/api/places"; import { - fetchPlacePreview, placePreviewQueryKey, resolvePreviewGooglePlaceId, } from "@/lib/places/place-queries"; @@ -153,33 +152,19 @@ function buildPlanPlacesFromItemsWithCache( ); } -/** batch 시딩 후 preview 캐시 miss 항목만 GET `/preview`로 보강합니다. */ +/** preview 캐시 miss 항목만 batch로 받아 채웁니다 — 그래도 없는 장소는 단건으로 다시 받지 않고 자리표시자로 둡니다. */ export async function buildPlanPlacesFromItemsWithPreviewEnrichment( queryClient: QueryClient, items: RoomScheduleItem[], ): Promise { - const sorted = sortRoomScheduleItemsByOrder(items); - const placeIds = sorted + const placeIds = items .map((item) => item.googlePlaceId) .filter((id) => typeof id === "string" && id.trim().length > 0); if (placeIds.length) { await fetchAndSeedPlacePreviews(placeIds, queryClient); } - - const out: PlanPlace[] = []; - for (const item of sorted) { - let preview = readCachedPreview(queryClient, item.googlePlaceId); - if (!preview) { - try { - preview = await fetchPlacePreview(item.googlePlaceId); - } catch { - preview = null; - } - } - out.push(planPlaceFromItemAndPreview(item, preview)); - } - return out; + return buildPlanPlacesFromItemsWithCache(queryClient, items); } function collectGooglePlaceIdsFromSchedules( @@ -217,7 +202,7 @@ export async function buildSchedulePlacesFromSchedulesWithItems( const items = Array.isArray(schedule.items) ? schedule.items : []; placesBySchedule.set( sid, - await buildPlanPlacesFromItemsWithPreviewEnrichment(queryClient, items), + buildPlanPlacesFromItemsWithCache(queryClient, items), ); } return placesBySchedule; @@ -275,6 +260,12 @@ function batchRouteItemToResponse( const inFlightScheduleRoutesBatch = new Map>(); +/** + * 실패가 아니라 "경로 없음"이라는 답인 batch 항목 — 단건으로 다시 물어도 같은 답(204·404)이 오므로 + * 경로 없음(`null`)으로 시딩하고 다시 묻지 않는다. + */ +const ROUTE_ANSWER_ERROR_CODES = new Set(["NO_ROUTE", "LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"]); + /** * in-flight dedup 키 — 일정 지문(`itemId` 순서·장소 정체성)을 포함해 * 목적지만 바뀐 일정(`[A,B]`→`[A,C]`)이 이전 batch Promise를 공유하지 않게 합니다. @@ -398,17 +389,17 @@ export async function hydrateScheduleRoutesBatch( if (!snapshotFp.length || snapshotFp !== currentFp) return; for (const result of results) { - if (!isBatchItemOk(result)) continue; - const route = batchRouteItemToResponse(result); const itemId = result.itemId; const mode = canonicalScheduleTravelMode(result.travelMode); if (!mode) continue; if (typeof itemId !== "number" || !Number.isFinite(itemId)) continue; - queryClient.setQueryData( - scheduleItemRouteQueryKey(rid, scheduleId, itemId, mode), - route, - ); + const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, mode); + if (isBatchItemOk(result)) { + queryClient.setQueryData(routeKey, batchRouteItemToResponse(result)); + } else if (result.errorCode && ROUTE_ANSWER_ERROR_CODES.has(result.errorCode)) { + queryClient.setQueryData(routeKey, null); + } } } diff --git a/src/lib/plan/schedule-preview-hydration.test.ts b/src/lib/plan/schedule-preview-hydration.test.ts new file mode 100644 index 00000000..ccf25fcc --- /dev/null +++ b/src/lib/plan/schedule-preview-hydration.test.ts @@ -0,0 +1,201 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RoomScheduleWithItems } from "@/lib/api/rooms/schedules"; +import type { PlanPlace } from "@/lib/plan/types"; + +const requestPlacePreviewsBatch = vi.hoisted(() => vi.fn()); +const requestPlacePreview = vi.hoisted(() => vi.fn()); + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_API_BASE_URL = "http://localhost:8080"; + process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "test-client"; + process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI = "http://localhost/callback"; + process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-maps-key"; +}); + +vi.mock("@/lib/api/places", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, requestPlacePreviewsBatch, requestPlacePreview }; +}); + +let hydrateScheduleItemsFromSchedulesWithItems: typeof import("@/lib/plan/schedule-bulk-hydration").hydrateScheduleItemsFromSchedulesWithItems; +let ensureSchedulePlanPlacesEnriched: typeof import("@/lib/plan/scheduleItemPlaces").ensureSchedulePlanPlacesEnriched; +let PLAN_PLACE_PREVIEW_ERROR_TITLE: string; +let createQueryClient: typeof import("@/lib/query-client").createQueryClient; +let scheduleItemsQueryKey: typeof import("@/lib/query-keys").scheduleItemsQueryKey; +let roomSchedulesQueryKey: typeof import("@/lib/query-keys").roomSchedulesQueryKey; +let HttpError: typeof import("@/lib/api/errors").HttpError; + +beforeAll(async () => { + ({ hydrateScheduleItemsFromSchedulesWithItems, PLAN_PLACE_PREVIEW_ERROR_TITLE } = await import( + "@/lib/plan/schedule-bulk-hydration" + )); + ({ ensureSchedulePlanPlacesEnriched } = await import("@/lib/plan/scheduleItemPlaces")); + ({ createQueryClient } = await import("@/lib/query-client")); + ({ scheduleItemsQueryKey, roomSchedulesQueryKey } = await import("@/lib/query-keys")); + ({ HttpError } = await import("@/lib/api/errors")); +}); + +beforeEach(() => { + requestPlacePreviewsBatch.mockReset(); + requestPlacePreview.mockReset(); +}); + +const ROOM_ID = "room-1"; + +function schedule(scheduleId: number, googlePlaceIds: string[]): RoomScheduleWithItems { + return { + scheduleId, + roomId: ROOM_ID, + dayNumber: scheduleId, + date: "2026-11-01", + createdAt: "2026-10-01T00:00:00", + items: googlePlaceIds.map((googlePlaceId, orderIndex) => ({ + itemId: scheduleId * 100 + orderIndex, + scheduleId, + googlePlaceId, + startTime: null, + endTime: null, + orderIndex, + travelMode: "DRIVING", + createdAt: "2026-10-01T00:00:00", + })), + }; +} + +const ok = (googlePlaceId: string) => ({ + status: "OK", + googlePlaceId, + name: `이름 ${googlePlaceId}`, + formattedAddress: "서울", + location: { latitude: 37.5, longitude: 127.0 }, + primaryType: "cafe", + primaryTypeDisplayName: "카페", +}); +const failed = (googlePlaceId: string) => ({ + status: "FAILED", + googlePlaceId, + errorCode: "EXTERNAL_API_ERROR", +}); + +function titles(queryClient: ReturnType, scheduleId: number) { + return ( + queryClient.getQueryData(scheduleItemsQueryKey(ROOM_ID, scheduleId)) ?? [] + ).map((place) => place.title); +} + +describe("hydrateScheduleItemsFromSchedulesWithItems", () => { + it("모든 일차 장소를 batch 한 번으로 받고 실패한 곳만 batch로 한 번 더 받는다 — 단건으로는 받지 않는다", async () => { + const queryClient = createQueryClient(); + requestPlacePreviewsBatch + .mockResolvedValueOnce([ok("a"), failed("b"), ok("c"), failed("d")]) + .mockResolvedValueOnce([ok("b"), failed("d")]); + + await hydrateScheduleItemsFromSchedulesWithItems(queryClient, ROOM_ID, [ + schedule(1, ["a", "b"]), + schedule(2, ["c", "d"]), + ]); + + expect(requestPlacePreviewsBatch.mock.calls).toEqual([[["a", "b", "c", "d"]], [["b", "d"]]]); + expect(requestPlacePreview).not.toHaveBeenCalled(); + expect(titles(queryClient, 1)).toEqual(["이름 a", "이름 b"]); + expect(titles(queryClient, 2)).toEqual(["이름 c", PLAN_PLACE_PREVIEW_ERROR_TITLE]); + }); + + it("미리보기 batch 요청이 실패해도 일정은 자리표시자로 채우고 다시 보내지 않는다", async () => { + const queryClient = createQueryClient(); + requestPlacePreviewsBatch.mockRejectedValueOnce(new HttpError(429, "요청이 너무 많습니다")); + + await hydrateScheduleItemsFromSchedulesWithItems(queryClient, ROOM_ID, [schedule(1, ["a", "b"])]); + + expect(requestPlacePreviewsBatch).toHaveBeenCalledTimes(1); + expect(requestPlacePreview).not.toHaveBeenCalled(); + expect(titles(queryClient, 1)).toEqual([ + PLAN_PLACE_PREVIEW_ERROR_TITLE, + PLAN_PLACE_PREVIEW_ERROR_TITLE, + ]); + }); +}); + +describe("ensureSchedulePlanPlacesEnriched", () => { + const placeholder = (googlePlaceId: string, itemId: number): PlanPlace => ({ + id: `item-${itemId}`, + itemId, + googlePlaceId, + title: PLAN_PLACE_PREVIEW_ERROR_TITLE, + subtitle: googlePlaceId, + }); + + it("일정 불러오기가 진행 중이면 끝날 때까지 기다려 그 결과를 쓰고, 미리보기를 다시 받지 않는다", async () => { + const queryClient = createQueryClient(); + let finishHydration: (schedules: RoomScheduleWithItems[]) => void = () => {}; + const hydration = queryClient.fetchQuery({ + queryKey: roomSchedulesQueryKey(ROOM_ID), + queryFn: () => + new Promise((resolve) => { + finishHydration = resolve; + }), + }); + + const enriching = ensureSchedulePlanPlacesEnriched(queryClient, ROOM_ID, 1); + const hydratedPlaces = [placeholder("a", 100)]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, 1), hydratedPlaces); + finishHydration([schedule(1, ["a"])]); + await hydration; + + await expect(enriching).resolves.toBe(hydratedPlaces); + expect(requestPlacePreviewsBatch).not.toHaveBeenCalled(); + expect(requestPlacePreview).not.toHaveBeenCalled(); + }); + + it("기다리던 일정 불러오기가 취소되고 다시 시작되면 새 불러오기를 기다려 그 결과를 쓴다", async () => { + const queryClient = createQueryClient(); + // 이미 받은 일정을 다시 받는 중에 또 다시 받으면 TanStack이 진행 중인 불러오기를 취소한다 + queryClient.setQueryData(roomSchedulesQueryKey(ROOM_ID), [schedule(1, ["a"])]); + const finishHydrations: Array<(schedules: RoomScheduleWithItems[]) => void> = []; + const queryFn = () => + new Promise((resolve) => { + finishHydrations.push(resolve); + }); + void queryClient.fetchQuery({ queryKey: roomSchedulesQueryKey(ROOM_ID), queryFn, staleTime: 0 }); + + const enriching = ensureSchedulePlanPlacesEnriched(queryClient, ROOM_ID, 1); + const refetching = queryClient.refetchQueries({ + queryKey: roomSchedulesQueryKey(ROOM_ID), + exact: true, + }); + const hydratedPlaces = [placeholder("a", 100)]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, 1), hydratedPlaces); + finishHydrations[1]([schedule(1, ["a"])]); + await refetching; + + await expect(enriching).resolves.toBe(hydratedPlaces); + expect(finishHydrations).toHaveLength(2); + expect(requestPlacePreviewsBatch).not.toHaveBeenCalled(); + expect(requestPlacePreview).not.toHaveBeenCalled(); + }); + + it("다시 들어오면 지금 일차 목록은 그대로 두고 자리표시자 장소만 다시 받는다 — 지운 장소가 되살아나지 않는다", async () => { + const queryClient = createQueryClient(); + // 일정을 불러온 뒤 c를 지웠다 — room-schedules의 items에는 c가 아직 남아 있다 + queryClient.setQueryData(roomSchedulesQueryKey(ROOM_ID), [schedule(1, ["a", "b", "c"])]); + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, 1), [ + placeholder("a", 100), + { id: "item-101", itemId: 101, googlePlaceId: "b", title: "이름 b", subtitle: "서울" }, + ]); + requestPlacePreview.mockResolvedValueOnce({ + googlePlaceId: "a", + name: "이름 a", + formattedAddress: "서울", + location: { latitude: 37.5, longitude: 127.0 }, + primaryType: "cafe", + primaryTypeDisplayName: "카페", + }); + + const places = await ensureSchedulePlanPlacesEnriched(queryClient, ROOM_ID, 1); + + expect(places.map((place) => place.title)).toEqual(["이름 a", "이름 b"]); + expect(requestPlacePreview).toHaveBeenCalledExactlyOnceWith("a"); + expect(requestPlacePreviewsBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 264a5faf..3936ce31 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -1,7 +1,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { awaitRoomSchedulesHydrated } from "@/lib/rooms"; +import { awaitRoomSchedulesHydrated, waitForRoomSchedulesHydration } from "@/lib/rooms"; import { createScheduleItem, @@ -10,9 +10,14 @@ import { type CreateScheduleItemResponse, type RoomScheduleItem, } from "@/lib/api/rooms/schedule-items"; +import { + fetchAndSeedPlacePreviews, + reconcileSchedulePlanPlacesAfterPreviewSeed, +} from "@/lib/places/place-batch-cache"; import { fetchPlacePreview } from "@/lib/places/place-queries"; import { buildPlanPlacesFromItemsWithPreviewEnrichment, + PLAN_PLACE_PREVIEW_ERROR_TITLE, planPlacesNeedPreviewEnrich, resolveScheduleItemsFromCacheOrHydrate, } from "@/lib/plan/schedule-bulk-hydration"; @@ -398,10 +403,22 @@ export async function ensureSchedulePlanPlacesEnriched( const rid = roomId.trim(); if (!rid.length) return []; const key = scheduleItemsQueryKey(rid, scheduleId); + // 일정 불러오기가 진행 중이면 그 결과를 쓴다 — 방금 batch로 받은 장소를 일차마다 다시 받지 않는다 + const waitedForHydration = await waitForRoomSchedulesHydration(queryClient, rid); const cached = queryClient.getQueryData(key); - if (cached != null && !planPlacesNeedPreviewEnrich(cached)) { + if (cached != null && (waitedForHydration || !planPlacesNeedPreviewEnrich(cached))) { return cached; } + if (cached != null) { + // 지금 일차 목록(삭제·수정·순서가 반영됨)은 그대로 두고 자리표시자 장소의 미리보기만 채운다 — + // room-schedules의 items는 장소 단위 변경을 따라가지 않아, 거기서 다시 만들면 지운 장소가 되살아난다 + const placeholderIds = cached + .filter((place) => place.title === PLAN_PLACE_PREVIEW_ERROR_TITLE) + .flatMap((place) => (place.googlePlaceId ? [place.googlePlaceId] : [])); + await fetchAndSeedPlacePreviews(placeholderIds, queryClient); + reconcileSchedulePlanPlacesAfterPreviewSeed(queryClient, rid); + return queryClient.getQueryData(key) ?? cached; + } const items = await resolveScheduleItemsFromCacheOrHydrate( queryClient, diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts index b05a43f4..65afd3ff 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts @@ -11,6 +11,7 @@ vi.mock("@/lib/api/rooms/schedule-items", async (importOriginal) => { }); let persistedScheduleItemRouteQueryOptions: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").persistedScheduleItemRouteQueryOptions; +let loadScheduleItemRouteForMap: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").loadScheduleItemRouteForMap; let registerQueryClient: typeof import("@/lib/query-client").registerQueryClient; let getScheduleItemRouteMock: ReturnType; @@ -20,7 +21,7 @@ beforeAll(async () => { process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI = "http://localhost/callback"; process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-maps-key"; - ({ persistedScheduleItemRouteQueryOptions } = await import( + ({ persistedScheduleItemRouteQueryOptions, loadScheduleItemRouteForMap } = await import( "@/lib/plan/scheduleItemRoutePersistedQuery" )); ({ registerQueryClient } = await import("@/lib/query-client")); @@ -68,3 +69,31 @@ describe("persistedScheduleItemRouteQueryOptions", () => { expect(result).toEqual(freshRoute); }); }); + +describe("loadScheduleItemRouteForMap", () => { + const route = { travelMode: "TRANSIT", distanceMeters: 1700, durationSeconds: 1140 }; + const routeOptions = () => + persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "TRANSIT", "", { segmentReady: true }); + + it("목록 요청이 방금 실패한 구간은 다시 보내지 않고 가진 값을 쓴다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const options = routeOptions(); + queryClient.setQueryData(options.queryKey, route); + await queryClient.invalidateQueries({ queryKey: options.queryKey, refetchType: "none" }); + getScheduleItemRouteMock.mockRejectedValueOnce(new Error("이동 정보 조회 실패: 429")); + await queryClient.fetchQuery(options).catch(() => undefined); + + await expect(loadScheduleItemRouteForMap(queryClient, options)).resolves.toEqual(route); + expect(getScheduleItemRouteMock).toHaveBeenCalledOnce(); + }); + + it("받은 적 없는 구간은 경로 쿼리로 받는다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + getScheduleItemRouteMock.mockResolvedValueOnce(route); + + await expect(loadScheduleItemRouteForMap(queryClient, routeOptions())).resolves.toEqual(route); + expect(getScheduleItemRouteMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.ts index 1bfafba1..70d4346c 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.ts @@ -1,5 +1,6 @@ import type { OmitKeyof, + QueryClient, QueryKey, UseQueryOptions, } from "@tanstack/react-query"; @@ -103,3 +104,18 @@ export function persistedScheduleItemRouteQueryOptions( retry: false, }; } + +/** + * 지도 구간 경로 — 목록과 같은 경로 쿼리를 쓴다(진행 중이면 합류). 방금 받다가 실패한 구간은 다시 보내지 않고 + * 가진 값(없으면 null)을 쓴다 — 실패(429·5xx) 직후 같은 구간을 지도가 또 요청하지 않게 한다. + */ +export async function loadScheduleItemRouteForMap( + queryClient: QueryClient, + options: ReturnType, +): Promise { + const state = queryClient.getQueryState(options.queryKey); + if (state?.status === "error" && state.fetchStatus === "idle") { + return state.data ?? null; + } + return queryClient.fetchQuery(options); +} diff --git a/src/lib/query-client.test.ts b/src/lib/query-client.test.ts index 7bdbad82..d5dc8fa4 100644 --- a/src/lib/query-client.test.ts +++ b/src/lib/query-client.test.ts @@ -21,3 +21,22 @@ describe("getOrCreateQueryClient", () => { expect(queryClientModule.getQueryClient()).toBe(first); }); }); + +describe("shouldRetryQuery", () => { + it("HTTP 오류(429·4xx·5xx)는 다시 보내지 않는다", async () => { + const { shouldRetryQuery } = await import("@/lib/query-client"); + const { HttpError } = await import("@/lib/api/errors"); + + for (const status of [400, 401, 404, 429, 500, 502, 503, 504]) { + expect(shouldRetryQuery(0, new HttpError(status, "failed"))).toBe(false); + } + expect(shouldRetryQuery(0, new Error("일정 목록 조회 실패: 500"))).toBe(false); + }); + + it("일시적 네트워크 오류는 한 번만 다시 보낸다", async () => { + const { shouldRetryQuery } = await import("@/lib/query-client"); + + expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true); + expect(shouldRetryQuery(1, new TypeError("Failed to fetch"))).toBe(false); + }); +}); diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts index cedede77..7953a7d4 100644 --- a/src/lib/query-client.ts +++ b/src/lib/query-client.ts @@ -1,12 +1,19 @@ import { QueryClient } from "@tanstack/react-query"; +import { isNetworkError } from "@/lib/api/errors"; + let registeredQueryClient: QueryClient | null = null; +/** 일시적 네트워크 오류만 한 번 더 보낸다 — Google 429도 백엔드에서 502로 오므로 HTTP 오류는 재시도하지 않는다 */ +export function shouldRetryQuery(failureCount: number, error: unknown): boolean { + return failureCount < 1 && isNetworkError(error); +} + export function createQueryClient() { return new QueryClient({ defaultOptions: { queries: { - retry: 1, + retry: shouldRetryQuery, refetchOnWindowFocus: false, staleTime: 60_000, }, diff --git a/src/lib/rooms.ts b/src/lib/rooms.ts index 02c4ea60..9340fca2 100644 --- a/src/lib/rooms.ts +++ b/src/lib/rooms.ts @@ -1,4 +1,4 @@ -import type { QueryClient } from "@tanstack/react-query"; +import { isCancelledError, type QueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api/client"; import type { @@ -168,6 +168,38 @@ export async function awaitRoomSchedulesHydrated( }); } +/** + * `useRoomSchedules`의 일정 불러오기가 진행 중이면 끝날 때까지 기다리고 true를 돌려줍니다. + * 진행 중인 요청에 합류할 뿐 새로 보내지 않습니다 — 진행 중이 아니면 바로 false입니다. + * 기다리던 불러오기가 취소되고 새로 시작되면 새 불러오기를 기다립니다. + */ +export async function waitForRoomSchedulesHydration( + queryClient: QueryClient, + roomId: string, +): Promise { + const rid = roomId.trim(); + const queryKey = roomSchedulesQueryKey(rid); + if (!rid.length || queryClient.isFetching({ queryKey, exact: true }) === 0) { + return false; + } + + // 합류한 불러오기가 취소되면 그 promise는 CancelledError로 끝난다. 그대로 던지면 이 함수를 부른 + // 일차 조회가 성공도 실패도 아닌 '조회 중'으로 남는다(TanStack이 취소 에러는 상태로 남기지 않음) + while (queryClient.isFetching({ queryKey, exact: true }) > 0) { + try { + await queryClient.fetchQuery({ + queryKey, + queryFn: () => hydrateRoomSchedulesFromServer(queryClient, rid), + staleTime: 0, + }); + return true; + } catch (error) { + if (!isCancelledError(error)) throw error; + } + } + return true; +} + /** * 일정 전체를 다시 받습니다(본인의 일차 생성·삭제·이동, 여행 날짜 변경 직후). * 진행 중이던 조회는 이 변경 전 스냅샷이라 취소하고, `room-schedules` 조회 하나로 받아 From f0d18e5c44f17bfd75a30ef8ece017fe02b2cd4b Mon Sep 17 00:00:00 2001 From: KangShingyu <103213494+KangShinGyu98@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:12:13 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor(ui):=20=EA=B3=B5=ED=86=B5=20UI?= =?UTF-8?q?=EC=99=80=20=EB=8C=80=ED=99=94=EC=83=81=EC=9E=90=EB=A5=BC=20sha?= =?UTF-8?q?dcn(Base=20UI)=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9=20(#253)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ui): shadcn base-nova 설치와 컴포넌트 코드 받기 - @base-ui/react, class-variance-authority 설치 - components.json 작성(style base-nova, init 없이 globals.css 유지) - shadcn add로 sonner·spinner·alert·skeleton·badge·avatar·toggle·field·input·textarea·checkbox·switch·radio-group 받기(label·separator는 field가 함께 받음) - 받은 코드는 그대로 두고 적용할 때 디자인 시스템에 맞춰 고친다. add가 함께 설치한 cn·next-themes도 그때 정리한다 Co-Authored-By: Claude Opus 5.5 * docs(conventions): shadcn 원칙을 Base UI·cva 기준으로 수정 - 원칙 1-1: Radix 대신 Base UI(base-nova), init 없이 add로만 받기, cn import·dark: 정리, asChild 대신 render - 원칙 3-6·안티패턴 17: 변형은 클래스 맵 대신 cva로 모으기 - Design.md 1-3·3-3: 설치 전 문구 정리 Co-Authored-By: Claude Opus 5.5 * feat(icons): shadcn 부품에 쓸 Figma 아이콘 추가 - Toast: circle-check(Positive)·circle-ban(Negative) - Spinner: progress - Checkbox·Radio: checkbox·checkbox-activated·radio-button(Figma 원본 선 2px·1.8px 유지) - Avatar 대체: user - 지도 카테고리 Chip: travel-restaurant·hotel·themepark·museum·train - Design.md 2-1대로 width·height 삭제, currentColor, 원본에 있던 24×24 clipPath 제거 Co-Authored-By: Claude Opus 5.5 * style(ui): Toast를 디자인 시스템 Toast에 맞춤 - Figma Toast Base(5285:5064) 값으로 그림: 최소 높이 52, 여백 4·8·4·16, 간격 8, 모서리 8, border/default 1px, fill/elevation, 그림자 없음 - sonner 기본 CSS가 레이어 밖이라 unstyled + classNames로 토큰만 씀, 라이트 테마 고정 - 아이콘: Positive circle-check, Negative circle-ban(20px), 일반 안내는 아이콘 없음 - 닫기 44px·액션 XS Neutral Weak 모양, 접근성 라벨 한국어('알림', '닫기') - next-themes·lucide·shadcn CSS 변수 제거, 화면은 위치·여백·시간만 넘기도록 Props 제한 - 쓰는 곳이 없어진 next-themes 의존성 삭제 Co-Authored-By: Claude Opus 5.5 * style(ui): Spinner를 디자인 시스템 progress 아이콘으로 그림 - Figma progress 아이콘(252:30981)과 Button Loading 색: 기본 icon/default, 버튼 안 inverse - 크기는 Figma 아이콘 크기(12·16·20·24·28·32, 기본 20)만 받고 svg에 px 클래스로도 줌(shadcn 부모 규칙에 덮이지 않게) - 루트는 output(암묵 status), 기본 aria-label '불러오는 중', 동작 줄이기에서는 회전 멈춤 - lucide·cn 패키지 제거 Co-Authored-By: Claude Opus 5.5 * style(ui): Alert를 지금 화면의 안내 상자 모양으로 정리 - Figma에 인라인 Alert가 없어 지금 상자 3종을 tone으로 옮김: neutral 회색 상자, brand 파란 틴트, warning 주황 테두리 - size lg(로그인·약관 화면)·md(앱 안 상자), orientation vertical·horizontal(동작 위치) - 금지 색(primary 투명도, muted-brown 등)은 가까운 토큰·원시 색으로 바꿈 - 폭·바깥 margin을 두지 않아 넘치지 않게 함, role="alert" 기본(덮어쓰기 가능) - shadcn 아이콘 칸·absolute 동작 위치·cn 패키지 제거 Co-Authored-By: Claude Opus 5.5 * style(ui): Skeleton을 지금 화면 스켈레톤 모양으로 정리 - Figma에 없어 지금 블록 모양을 기본값으로: fill-strong, 0.25rem 모서리, 펄스, 동작 줄이기에서 멈춤 - tone subtle(fill)로 일정 목록 스켈레톤의 옅은 톤을 보존 - render={} 지원(output·span 안), 기본 block, aria-hidden - bg-muted(우때에 없는 색)·cn 패키지 제거 Co-Authored-By: Claude Opus 5.5 * style(ui): Badge를 디자인 시스템 Badge에 맞춤 - Figma 15 Badges: variant weak·solid·outline × tone 6종 × size lg(24)·md(20) - Figma 값이 코드 토큰과 다른 색(primary/subtle은 blue-50, status subtle·strong)은 원시 변수로 맞춤 - 모서리 4px, 아이콘 lg 14·md 12px 고정, 비조작 라벨이라 hover·focus 없음 - shadcn variant(default·secondary·destructive 등)·cn 패키지 제거 Co-Authored-By: Claude Opus 5.5 * style(ui): Avatar를 지금 화면 아바타 모양으로 정리 - Figma Avatar가 없어 지금 크기(24·28·32·36·40·56)·모양(원·둥근 사각)·톤·접속 링을 variant로 옮김 - 기본값은 Figma Header Profile(36px, fill/strong 원) - 사진: referrer 없이 요청, 받는 동안 배경색, 실패하면 바로 대체, 주소가 바뀌어도 새 사진이 올 때까지 이전 사진 유지 - AvatarGroup·AvatarGroupCount는 버튼 안에 둘 수 있게 span으로, 쓰지 않는 AvatarBadge 삭제 Co-Authored-By: Claude Opus 5.5 * style(ui): Chip을 디자인 시스템 Chip에 맞춤 - toggle.tsx를 chip.tsx로 바꿈: Base UI Toggle 위의 Chip, 모양만 맡는 chipVariants(제거용 칩 등 버튼이 아닌 칩용) - Figma 14 Chips: Solid·Outline × lg 44·md 36·sm 32, Selected(aria-pressed)·Pressed(:active)·Disabled 색 - Outline 선택+눌림 배경은 원시 변수로 Figma primary/subtle(blue-50) - 120ms 색 전환, 모바일에서 md·sm 누르는 영역만 44px로 넓힘 - hover·그림자 없음(Figma에 없음) Co-Authored-By: Claude Opus 5.5 * style(ui): Field·Input·Textarea를 디자인 시스템 Text Field에 맞춤 - Input: Figma Text Field 48px, Focused·Error·Disabled·Read Only를 DOM 상태(:focus·aria-invalid·:disabled·:read-only)로 그림 - Textarea: Figma Textarea Field 96px 고정 높이와 스크롤 - Field: Figma Header·Footer에 맞춰 FieldHeader·FieldLabel·FieldSuffix·FieldFooter·FieldDescription·FieldError·FieldCount - 오류 글자는 status/negative/strong(원시 변수 red-800), 오류 테두리는 status-negative - 쓰지 않는 Field 부품·separator.tsx와 이름 없는 role="group" 제거 Co-Authored-By: Claude Opus 5.5 * style(ui): Checkbox를 디자인 시스템 checkbox 아이콘으로 그림 - Figma checkbox 아이콘: 해제는 border/default 외곽선, 선택은 primary 외곽선과 체크 - size md 24·sm 20(가계부 결제자·부담자 시안 크기) - 비활성은 커서만 바꿈(저장 중 깜빡임 방지), 일부 선택(indeterminate)은 받지 않음 - lucide·cn 패키지 제거 Co-Authored-By: Claude Opus 5.5 * style(ui): Switch를 지금 화면 스위치 모양으로 정리 - Figma에 없어 쿠키 설정(md 트랙 48×28, 누르는 영역 56×44)과 경로 표시(sm 36×20) 모양을 옮김 - 지금처럼 - … +// ✓ 동작은 Base UI, 모양은 토큰을 입힌 ui 부품. 저장 중에는 closeDisabled로 Esc·딤·X를 막는다 + + + 초대 + … + + }>취소 + + + ``` +- Base UI 부품은 기본 요소(Trigger는 ` +``` + ## 3. 퍼블리싱 ### 3-1. Auto Layout을 그대로 옮긴다 (Layout First) @@ -534,7 +567,7 @@ z-index는 같은 스태킹 컨텍스트 안에서만 비교된다. 그래서 - 타이포는 토큰 하나로 쓴다. display·heading·title은 `text-<역할>-<크기>`(`text-title-m`), body·label·caption은 굵기까지 붙인다(`text-body-s-regular`). `text-body-s`처럼 없는 이름은 CSS가 생기지 않는다. - 타이포 토큰에 `font-*`·`leading-*`·`tracking-*`·`/N`을 덧붙이지 않는다(토큰 값을 덮거나 굵기·자간이 빠진다). 맞는 토큰이 없으면 Figma 값대로 크기·줄 높이·굵기를 함께 임의값으로 쓰고(`text-[17px]/[24px] font-semibold`) 근거 주석을 단다(Design.md 1-2). - radius는 재정의된 토큰을 쓴다(`rounded-md` = 8px, Tailwind 기본과 다르다). 그림자 토큰은 아직 없다. `shadow-sm` 같은 Tailwind 기본값 대신 Figma 값을 임의값과 근거 주석으로 쓴다. 같은 임의값이 반복되면 토큰 추가를 요청한다. -- 클래스는 `cn()`으로만 합친다. 템플릿 리터럴은 겹친 클래스(`px-2`와 바깥 `px-4`)를 정리하지 못하고, 조립한 이름(`` `bg-${tone}` ``)은 Tailwind가 소스에서 찾지 못한다. 변형은 `as const` 클래스 맵에 모은다. +- 클래스는 `cn()`으로만 합친다. 템플릿 리터럴은 겹친 클래스(`px-2`와 바깥 `px-4`)를 정리하지 못하고, 조립한 이름(`` `bg-${tone}` ``)은 Tailwind가 소스에서 찾지 못한다. 변형은 cva(`class-variance-authority`)로 모은다. - 상태 스타일은 aria 속성을 variant로 읽는다(`aria-expanded:bg-fill`, `aria-current="page"`는 `aria-[current=page]:`). aria가 없는 상태만 `data-*`로 둔다. 여러 하위가 함께 따르는 루트 상태(채팅 최소화·밀도)는 하위마다 prop으로 내리지 않고, 루트의 `data-minimized`를 하위가 `group-data-minimized/chat:`처럼 읽는다. - 전역 요소 스타일은 `@layer base`에 둔다. 레이어 밖 CSS는 모든 유틸리티를 이긴다. @@ -542,12 +575,17 @@ z-index는 같은 스태킹 컨텍스트 안에서만 비교된다. 그래서 // ✗ 문자열 결합과 조립한 클래스 이름 {label} -// ✓ 완성된 클래스 맵과 cn -const TONE_CLASS = { - neutral: "bg-fill text-text", - brand: "bg-primary-subtle text-primary", -} as const; -{label} +// ✓ 변형은 cva, 합치기는 cn +const tagVariants = cva("rounded-full px-2", { + variants: { + tone: { + neutral: "bg-fill text-text", + brand: "bg-primary-subtle text-primary", + }, + }, + defaultVariants: { tone: "neutral" }, +}); +{label} ``` ### 3-7. 시맨틱 마크업과 접근성 (Semantic HTML) @@ -575,7 +613,7 @@ const TONE_CLASS = { | 원칙 | 출처 | | :-- | :-- | | 0 | React [Thinking in React](https://react.dev/learn/thinking-in-react)·[Keeping Components Pure](https://react.dev/learn/keeping-components-pure), Josh Comeau [Understanding Layout Algorithms](https://www.joshwcomeau.com/css/understanding-layout-algorithms/) | -| 1-1 | [shadcn/ui](https://ui.shadcn.com/docs) | +| 1-1 | [shadcn/ui](https://ui.shadcn.com/docs), [Base UI](https://base-ui.com/react/overview/quick-start) | | 1-3 | React [Thinking in React](https://react.dev/learn/thinking-in-react), Matt Pocock [codebase-design](https://github.com/mattpocock/skills)(삭제 테스트) | | 1-4 | Vercel [composition-patterns](https://github.com/vercel-labs/agent-skills/tree/main/skills/composition-patterns) `architecture-avoid-boolean-props`·`patterns-explicit-variants`·`patterns-children-over-render-props` | | 2-1 | React [Choosing the State Structure](https://react.dev/learn/choosing-the-state-structure), Vercel [react-best-practices](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices) `rerender-derived-state-no-effect` | @@ -590,4 +628,4 @@ const TONE_CLASS = { | 3-2 | Josh Comeau [The Height Enigma](https://www.joshwcomeau.com/css/height-enigma/), [Defensive CSS](https://defensivecss.dev/), web.dev [Viewport units](https://web.dev/blog/viewport-units) | | 3-3 | Josh Comeau [The Surprising Truth About Pixels and Accessibility](https://www.joshwcomeau.com/css/surprising-truth-about-pixels-and-accessibility/) | | 3-5 | Josh Comeau [What The Heck, z-index??](https://www.joshwcomeau.com/css/stacking-contexts/) | -| 3-6 | Tailwind [Detecting classes in source files](https://tailwindcss.com/docs/detecting-classes-in-source-files) | +| 3-6 | Tailwind [Detecting classes in source files](https://tailwindcss.com/docs/detecting-classes-in-source-files), [cva](https://cva.style/docs) | diff --git a/package-lock.json b/package-lock.json index 675fe989..bb66e5a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,15 @@ "version": "0.1.0", "dependencies": { "@amplitude/unified": "^1.1.28", + "@base-ui/react": "^1.9.0", "@ncdai/react-wheel-picker": "^1.2.3", "@next/third-parties": "^16.2.1", "@stomp/stompjs": "^7.3.0", + "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2", "@vis.gl/react-google-maps": "^1.8.2", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "lucide-react": "^1.7.0", @@ -2166,6 +2169,12 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.8.tgz", + "integrity": "sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==", + "license": "MIT" + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -2214,6 +2223,66 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.9.0.tgz", + "integrity": "sha512-S8CSYosASBKtpL8SWUfS78rFElfLijiCgA7oHOK22jCJ95FULsYyKwBNFOgudt0RA+/7yNc7F81bOvQnRgbBiQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@base-ui/utils": "0.5.0", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.7.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.3.0", + "use-sync-external-store": "^1.7.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -2562,6 +2631,44 @@ } } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@googlemaps/js-api-loader": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.0.2.tgz", @@ -4442,6 +4549,50 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.4.tgz", + "integrity": "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==", + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.33.5", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.33.5.tgz", + "integrity": "sha512-3dfx9MBP0aq5sXKteikG629X9oviptrQj0IFRk9YGcb+lB7Kv5x8S17oOSk1wUWgjQZ4xVJEMbKwOAODymocgA==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.100.9", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz", @@ -4462,6 +4613,46 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/react-form": { + "version": "1.33.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.33.5.tgz", + "integrity": "sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA==", + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "1.33.5", + "@tanstack/react-store": "^0.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-form/node_modules/@tanstack/react-store": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.2.tgz", + "integrity": "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.2", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/react-query": { "version": "5.100.9", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz", @@ -4495,6 +4686,16 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/store": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.2.tgz", + "integrity": "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -6001,6 +6202,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -11359,6 +11572,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -12833,6 +13052,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 9432c968..6dd9c67d 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,15 @@ }, "dependencies": { "@amplitude/unified": "^1.1.28", + "@base-ui/react": "^1.9.0", "@ncdai/react-wheel-picker": "^1.2.3", "@next/third-parties": "^16.2.1", "@stomp/stompjs": "^7.3.0", + "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2", "@vis.gl/react-google-maps": "^1.8.2", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "lucide-react": "^1.7.0", diff --git a/public/expenses/checkbox-checked.svg b/public/expenses/checkbox-checked.svg deleted file mode 100644 index 968b0219..00000000 --- a/public/expenses/checkbox-checked.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/expenses/checkbox.svg b/public/expenses/checkbox.svg deleted file mode 100644 index f38c06a5..00000000 --- a/public/expenses/checkbox.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/public/icons/packing/checkbox-checked.svg b/public/icons/packing/checkbox-checked.svg deleted file mode 100644 index 8fead7fd..00000000 --- a/public/icons/packing/checkbox-checked.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/icons/packing/checkbox.svg b/public/icons/packing/checkbox.svg deleted file mode 100644 index 1effd532..00000000 --- a/public/icons/packing/checkbox.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/public/map-search/attraction.svg b/public/map-search/attraction.svg deleted file mode 100644 index 30038055..00000000 --- a/public/map-search/attraction.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/public/map-search/clear.svg b/public/map-search/clear.svg deleted file mode 100644 index 129f6879..00000000 --- a/public/map-search/clear.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/map-search/hotel.svg b/public/map-search/hotel.svg deleted file mode 100644 index e3fad22b..00000000 --- a/public/map-search/hotel.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/public/map-search/museum.svg b/public/map-search/museum.svg deleted file mode 100644 index 50eb214f..00000000 --- a/public/map-search/museum.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/public/map-search/reset.svg b/public/map-search/reset.svg deleted file mode 100644 index f0c45e54..00000000 --- a/public/map-search/reset.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/map-search/restaurant.svg b/public/map-search/restaurant.svg deleted file mode 100644 index 09b7a658..00000000 --- a/public/map-search/restaurant.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/public/map-search/transit.svg b/public/map-search/transit.svg deleted file mode 100644 index 721fffd2..00000000 --- a/public/map-search/transit.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/public/rooms/figma/README.md b/public/rooms/figma/README.md index 0973baab..a21969e2 100644 --- a/public/rooms/figma/README.md +++ b/public/rooms/figma/README.md @@ -4,10 +4,9 @@ - 모바일: https://www.figma.com/design/3JnrQOfRePmycccIBfiPnZ?node-id=3314-5275 - 추출일: 2026-09-20 - `trip-cover.png`: Figma 원본 카드 사진. 현재 방 목록에서는 `public/rooms/covers/`의 사진 9장 중 방 ID로 선택한 이미지를 사용한다. -- `menu.svg`, `plus.svg`: Figma에서 내려받은 원본 아이콘. +- `plus.svg`: Figma에서 내려받은 원본 아이콘. - `plus-mobile.svg`: 모바일 생성 버튼의 20px 원본 아이콘. -- `user.svg`, `logout.svg`: 프로필 오버레이 원본 아이콘 (`3456:5418`). -- `invite.svg`, `trash.svg`: 방 더보기 오버레이 원본 아이콘 (`3449:3613`). +- `invite.svg`: 방 더보기 오버레이 원본 아이콘 (`3449:3613`). 더보기 버튼·프로필 메뉴·삭제 아이콘은 디자인 시스템 아이콘(`@/assets/icons`)을 쓴다. 광고 배너 영역에는 [마케팅 시안](https://www.figma.com/design/asf1pKmEosGS4xUx1qn3MV/%EB%A7%88%EC%BC%80%ED%8C%85?node-id=0-1)의 데스크톱·모바일 완성 배너 이미지를 각각 그대로 사용한다. 에셋은 만료되는 Figma 다운로드 URL 대신 로컬 파일을 참조한다. diff --git a/public/rooms/figma/logout.svg b/public/rooms/figma/logout.svg deleted file mode 100644 index 9cd3e7c2..00000000 --- a/public/rooms/figma/logout.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/public/rooms/figma/menu.svg b/public/rooms/figma/menu.svg deleted file mode 100644 index c1918c70..00000000 --- a/public/rooms/figma/menu.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/public/rooms/figma/trash.svg b/public/rooms/figma/trash.svg deleted file mode 100644 index b0dc1c9c..00000000 --- a/public/rooms/figma/trash.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/public/rooms/figma/user.svg b/public/rooms/figma/user.svg deleted file mode 100644 index 8cd8a3ab..00000000 --- a/public/rooms/figma/user.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/app/(main)/bookmark/[folderId]/page.tsx b/src/app/(main)/bookmark/[folderId]/page.tsx index f0ac1e8c..6e34b481 100644 --- a/src/app/(main)/bookmark/[folderId]/page.tsx +++ b/src/app/(main)/bookmark/[folderId]/page.tsx @@ -2,6 +2,7 @@ import { useParams, notFound } from "next/navigation"; import { useMemo } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; import { SetSectionMaxWidth } from "@/contexts/SectionWidthContext"; import { useBookmarkCategories } from "@/hooks/useRooms"; import { useSessionStore } from "@/stores/session-store"; @@ -11,19 +12,19 @@ import { BookmarkFolderDetailView } from "../_components/BookmarkFolderDetailVie function FolderDetailSkeleton() { return (
-
-
+ +
-
-
-
+ + +
); diff --git a/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx b/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx index 7b646559..fbccb9bf 100644 --- a/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx +++ b/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx @@ -1,6 +1,19 @@ "use client"; -import { useEffect, useState } from "react"; +import { useForm } from "@tanstack/react-form"; +import { useId, type ComponentProps } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Field, FieldError, FieldHeader, FieldLabel, FieldSuffix } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import type { BookmarkFolder } from "@/types/bookmark"; import { UNTITLED_BOOKMARK_CATEGORY_LABEL } from "@/lib/bookmark-untitled-category-name"; @@ -26,168 +39,169 @@ function initialTitle(mode: Mode, initialFolder: BookmarkFolder | null) { return ""; } -function initialColor(mode: Mode, initialFolder: BookmarkFolder | null) { +function initialColor(mode: Mode, initialFolder: BookmarkFolder | null): string { if (mode === "edit" && initialFolder) return initialFolder.color; return COLOR_PRESETS[0]; } +// 빈 제목은 허용한다(저장하는 쪽이 "제목 없는 북마크 N"을 고른다). maxLength가 있어도 한글 조합 중에는 한 글자 넘게 들어갈 수 있어 제출 때 길이를 다시 본다 +function titleError(title: string) { + return title.length > TITLE_MAX_LENGTH ? `제목은 ${TITLE_MAX_LENGTH}자까지 쓸 수 있어요.` : undefined; +} + export function AddBookmarkModal({ mode, initialFolder, busy = false, onClose, onSave, - overlayZClass = "z-50", /** 비어 있을 때 실제로 쓰이는 이름(다음 번호 반영). 없으면 기본 라벨만 표시 */ untitledNameHint, /** 서버/저장 오류 시 인라인 표시 (토스트 대신) */ formError = null, + /** 닫힌 뒤 포커스를 돌려줄 곳. 메뉴 항목에서 열면 사라진 항목 대신 메뉴 버튼을 준다 */ + finalFocus, }: { mode: Mode; initialFolder: BookmarkFolder | null; busy?: boolean; onClose: () => void; onSave: (payload: { title: string; color: string }) => void; - /** 상위 모달 위에 겹칠 때만 지정 (예: `z-[65]`) */ - overlayZClass?: string; untitledNameHint?: string; formError?: string | null; + finalFocus?: ComponentProps["finalFocus"]; }) { - const [title, setTitle] = useState(() => initialTitle(mode, initialFolder)); - const [color, setColor] = useState(() => initialColor(mode, initialFolder)); - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (busy) return; - const trimmed = title.trim(); - onSave({ - title: trimmed, - color, - }); - }; + const formId = useId(); + // 저장 요청(mutation)은 부모가 갖는다. 진행·실패는 부모의 busy·formError로 받는다 + const form = useForm({ + defaultValues: { + title: initialTitle(mode, initialFolder), + color: initialColor(mode, initialFolder), + }, + onSubmit: ({ value }) => { + onSave({ title: value.title.trim(), color: value.color }); + }, + }); return ( -
{ - if (ev.target === ev.currentTarget) onClose(); + { + if (!open) onClose(); }} > -
e.stopPropagation()} - > -

- {mode === "edit" ? "북마크 편집" : "새 북마크 생성"} -

+ + + {mode === "edit" ? "북마크 편집" : "새 북마크 생성"} + {formError ? ( -

+

{formError}

) : null} -
-
-
- - - {title.length}/{TITLE_MAX_LENGTH} - -
- setTitle(e.target.value)} - maxLength={TITLE_MAX_LENGTH} - placeholder={untitledNameHint ?? UNTITLED_BOOKMARK_CATEGORY_LABEL} - aria-describedby="bookmark-title-counter" - className="w-full rounded-xl border border-gray-border px-3 py-2.5 text-body-m-regular outline-none ring-primary/30 focus:border-primary focus:ring-2" - /> -
- -
- - 색상 - -
- {COLOR_PRESETS.map((hex) => ( -
-
- - setColor(e.target.value)} - className="h-10 w-14 cursor-pointer rounded-md border border-gray-border bg-white p-0.5" - /> - {color} -
-
+ { + event.preventDefault(); + // 라이브러리는 연타를 막지 않는다. busy는 부모 mutation의 isPending + if (busy || form.state.isSubmitting) return; + void form.handleSubmit(); + }} + > + titleError(value) }}> + {(field) => { + const error = field.state.meta.errors[0]; + const counterId = `${formId}-title-counter`; + const errorId = `${formId}-title-error`; + return ( + + + 제목 + + {field.state.value.length}/{TITLE_MAX_LENGTH} + + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + maxLength={TITLE_MAX_LENGTH} + placeholder={untitledNameHint ?? UNTITLED_BOOKMARK_CATEGORY_LABEL} + aria-invalid={error ? true : undefined} + aria-describedby={error ? `${counterId} ${errorId}` : counterId} + /> + {error} + + ); + }} + -
- - -
+ + {(field) => ( +
+ + 색상 + +
+ {COLOR_PRESETS.map((hex) => ( +
+
+ + field.handleChange(e.target.value)} + className="h-10 w-14 cursor-pointer rounded-md border border-gray-border bg-white p-0.5" + /> + + {field.state.value} + +
+
+ )} +
-
-
+ + + }>취소 + + + + ); } diff --git a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx index 4d7e5a29..249b6a51 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx @@ -3,7 +3,7 @@ import { BookmarkPlus, MoreHorizontal } from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { MAIN_CARD_INNER_PADDING_X_CLASS } from "@/lib/layout-tokens"; import { messageForBookmarkCategorySaveError } from "@/lib/api/errors"; @@ -16,6 +16,12 @@ import { } from "@/components/layout/page-toolbar-button"; import { MainPageHeader } from "@/components/layout/MainPageHeader"; import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { pickUniqueUntitledBookmarkCategoryName } from "@/lib/bookmark-untitled-category-name"; import { @@ -63,25 +69,9 @@ export function BookmarkFoldersView() { const [editingFolder, setEditingFolder] = useState( null, ); - const [menuOpenId, setMenuOpenId] = useState(null); const [folderFormError, setFolderFormError] = useState(null); - const menuRef = useRef(null); - - const closeMenu = useCallback(() => setMenuOpenId(null), []); - - useEffect(() => { - if (!menuOpenId) return; - const onPointer = (e: MouseEvent | TouchEvent) => { - const el = menuRef.current; - if (el && !el.contains(e.target as Node)) closeMenu(); - }; - document.addEventListener("mousedown", onPointer); - document.addEventListener("touchstart", onPointer); - return () => { - document.removeEventListener("mousedown", onPointer); - document.removeEventListener("touchstart", onPointer); - }; - }, [menuOpenId, closeMenu]); + // 편집 대화상자는 메뉴 항목에서 열려 닫힌 뒤 돌아갈 항목이 없다. 마지막으로 연 행 메뉴 버튼으로 포커스를 돌려준다 + const menuTriggerRef = useRef(null); const openCreate = () => { setFolderFormError(null); @@ -92,7 +82,6 @@ export function BookmarkFoldersView() { }; const openEdit = (folder: BookmarkFolder) => { - closeMenu(); setFolderFormError(null); setModalKey((k) => k + 1); setModalMode("edit"); @@ -160,7 +149,6 @@ export function BookmarkFoldersView() { const categoryId = Number.parseInt(folder.id, 10); if (!Number.isFinite(categoryId)) return; - closeMenu(); setFolderToDelete({ categoryId, title: folder.title }); }; @@ -284,53 +272,35 @@ export function BookmarkFoldersView() {

-
{ + if (open && trigger instanceof HTMLElement) menuTriggerRef.current = trigger; + }} > - - {menuOpenId === folder.id && ( -
+ + {/* 진행 중에는 항목을 비활성으로만 둔다(메뉴 항목은 Button이 아니라 progress가 없다) */} + openEdit(folder)} + > + 편집 + + handleDelete(folder)} > - - -
- )} -
+ 삭제 + + +
))} @@ -358,10 +328,12 @@ export function BookmarkFoldersView() { }} onSave={handleSave} formError={folderFormError} + finalFocus={modalMode === "edit" ? menuTriggerRef : undefined} /> )} {folderToDelete ? ( 북마크 불러오는 중 {[0, 1, 2].map((row) => ( - ); diff --git a/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx b/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx index 5afdf411..4a5a7a95 100644 --- a/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx @@ -2,6 +2,7 @@ import { MapPin } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { useInViewport } from "@/hooks/useInViewport"; import { usePlacePhotoUrlQuery } from "@/hooks/usePlacePhotoUrl"; import { useVisiblePlacePhotoUrl } from "@/hooks/useVisiblePlacePhotoUrl"; @@ -79,9 +80,9 @@ export function BookmarkPlacePreviewCard({ {name} {primaryTypeDisplayName ? ( - + {primaryTypeDisplayName} - + ) : null}
{address ? ( diff --git a/src/app/(main)/bookmark/_components/BookmarkPlaceRow.lifecycle.test.tsx b/src/app/(main)/bookmark/_components/BookmarkPlaceRow.lifecycle.test.tsx index 9bee1426..f39fa87f 100644 --- a/src/app/(main)/bookmark/_components/BookmarkPlaceRow.lifecycle.test.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkPlaceRow.lifecycle.test.tsx @@ -17,20 +17,23 @@ vi.mock("./BookmarkPlacePreviewCard", () => ({ BookmarkPlacePreviewCard: () => null, })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -it("positions the portal at open, updates category phase and closes outside with cleanup", async () => { - const rect = vi - .spyOn(HTMLElement.prototype, "getBoundingClientRect") - .mockReturnValue({ - top: 100, - bottom: 140, - left: 100, - right: 300, - width: 200, - height: 40, - x: 100, - y: 100, - toJSON: () => ({}), - }); + +const flush = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +const menu = () => document.querySelector('[role="menu"]'); +const items = () => + [...(menu()?.querySelectorAll('[role="menuitem"]') ?? [])].map( + (item) => item.textContent?.trim(), + ); +const item = (name: string) => + [...(menu()?.querySelectorAll('[role="menuitem"]') ?? [])].find( + (candidate) => candidate.textContent?.trim() === name, + )!; + +// 위치 계산(트리거 아래 배치·화면 크기 변경)은 Base UI Positioner가 맡아 픽셀 값은 검사하지 않는다 +it("switches to the category phase inside the open menu, closes outside, and reopens on the main phase", async () => { const container = document.createElement("div"); document.body.append(container); const root = createRoot(container); @@ -44,30 +47,30 @@ it("positions the portal at open, updates category phase and closes outside with />, ), ); - await act(async () => container.querySelector("button")!.click()); - let menu = document.querySelector('[role="menu"]')!; - expect(menu.style.top).toBe("144px"); - await act(async () => menu.querySelector("button")!.click()); - expect(menu.textContent).toContain("two"); - rect.mockReturnValue({ - top: 50, - bottom: 90, - left: 100, - right: 300, - width: 200, - height: 40, - x: 100, - y: 50, - toJSON: () => ({}), - }); - await act(async () => window.dispatchEvent(new Event("resize"))); - menu = document.querySelector('[role="menu"]')!; - expect(menu.style.top).toBe("94px"); + const trigger = container.querySelector( + 'button[aria-label="장소 메뉴"]', + )!; + await act(async () => trigger.click()); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect(items()).toEqual(["카테고리 변경", "삭제"]); + + await act(async () => item("카테고리 변경").click()); + expect(menu()).not.toBeNull(); + expect(items()).toEqual(["뒤로", "two"]); + await act(async () => - document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })), + document.body.dispatchEvent( + new MouseEvent("pointerdown", { bubbles: true }), + ), ); - expect(document.querySelector('[role="menu"]')).toBeNull(); + await flush(); + expect(menu()).toBeNull(); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + + await act(async () => trigger.click()); + expect(items()).toEqual(["카테고리 변경", "삭제"]); + await act(async () => root.unmount()); + expect(menu()).toBeNull(); container.remove(); - rect.mockRestore(); }); diff --git a/src/app/(main)/bookmark/_components/BookmarkPlaceRow.tsx b/src/app/(main)/bookmark/_components/BookmarkPlaceRow.tsx index d3b7dad9..01827782 100644 --- a/src/app/(main)/bookmark/_components/BookmarkPlaceRow.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkPlaceRow.tsx @@ -1,15 +1,15 @@ "use client"; import { ChevronLeft, MoreHorizontal } from "lucide-react"; -import { - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { createPortal } from "react-dom"; +import { useState } from "react"; import { toast } from "sonner"; import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { BookmarkPlacePreviewCard } from "./BookmarkPlacePreviewCard"; import { @@ -19,19 +19,9 @@ import { } from "@/hooks/useRooms"; import type { BookmarkedPlace } from "@/types/bookmark"; +// '카테고리 변경'은 하위 메뉴 대신 같은 메뉴 안에서 목록으로 바꿔 보인다(지금 화면 흐름 유지, 디자이너 질문으로 남음) type MenuPhase = "main" | "pickCategory"; -type MenuPosition = { - top: number; - left: number; - width: number; -}; - -const MENU_GAP_PX = 4; -const MENU_WIDTH_PX = 184; -const MENU_MAX_HEIGHT_PX = 352; -const MENU_VIEWPORT_PADDING_PX = 8; - export function BookmarkPlaceRow({ place, roomId, @@ -54,9 +44,6 @@ export function BookmarkPlaceRow({ const [menuOpen, setMenuOpen] = useState(false); const [menuPhase, setMenuPhase] = useState("main"); - const [menuPosition, setMenuPosition] = useState(null); - const triggerRef = useRef(null); - const menuPanelRef = useRef(null); const { data: categories } = useBookmarkCategories(roomId); const { mutate: moveBookmark, isPending: moving } = useMoveRoomBookmark(); @@ -68,78 +55,6 @@ export function BookmarkPlaceRow({ const otherCategories = categories?.filter((c) => c.categoryId !== currentCategoryId) ?? []; - const closeMenu = useCallback(() => { - setMenuOpen(false); - setMenuPhase("main"); - }, []); - - const updateMenuPosition = useCallback(() => { - const triggerEl = triggerRef.current; - if (!triggerEl || !menuOpen) { - setMenuPosition(null); - return; - } - const rect = triggerEl.getBoundingClientRect(); - const estimatedHeight = - menuPhase === "main" - ? 96 - : Math.min( - 64 + Math.max(otherCategories.length, 1) * 40, - MENU_MAX_HEIGHT_PX, - ); - const spaceBelow = window.innerHeight - rect.bottom; - const top = - spaceBelow >= estimatedHeight + MENU_GAP_PX - ? rect.bottom + MENU_GAP_PX - : Math.max( - MENU_VIEWPORT_PADDING_PX, - rect.top - MENU_GAP_PX - estimatedHeight, - ); - const left = Math.min( - Math.max( - MENU_VIEWPORT_PADDING_PX, - rect.right - MENU_WIDTH_PX, - ), - window.innerWidth - MENU_WIDTH_PX - MENU_VIEWPORT_PADDING_PX, - ); - setMenuPosition({ - top, - left, - width: MENU_WIDTH_PX, - }); - }, [menuOpen, menuPhase, otherCategories.length]); - - const attachMenuPanel = useCallback((node: HTMLDivElement | null) => { - menuPanelRef.current = node; - if (node) updateMenuPosition(); - }, [updateMenuPosition]); - - useEffect(() => { - if (!menuOpen) return; - window.addEventListener("scroll", updateMenuPosition, true); - window.addEventListener("resize", updateMenuPosition); - return () => { - window.removeEventListener("scroll", updateMenuPosition, true); - window.removeEventListener("resize", updateMenuPosition); - }; - }, [menuOpen, updateMenuPosition]); - - useEffect(() => { - if (!menuOpen) return; - const onPointer = (e: MouseEvent | TouchEvent) => { - const target = e.target as Node; - if (triggerRef.current?.contains(target)) return; - if (menuPanelRef.current?.contains(target)) return; - closeMenu(); - }; - document.addEventListener("mousedown", onPointer); - document.addEventListener("touchstart", onPointer); - return () => { - document.removeEventListener("mousedown", onPointer); - document.removeEventListener("touchstart", onPointer); - }; - }, [menuOpen, closeMenu]); - const handleMoveTo = (targetCategoryId: number) => { if (!Number.isFinite(bookmarkId) || busy) return; moveBookmark( @@ -149,13 +64,13 @@ export function BookmarkPlaceRow({ categoryId: targetCategoryId, fromCategoryId: currentCategoryId, }, - { onSuccess: () => closeMenu() }, + // 이동 항목은 누른 뒤에도 메뉴를 열어 두고(closeOnClick={false}) 성공하면 닫는다 + { onSuccess: () => setMenuOpen(false) }, ); }; const handleDelete = () => { if (!Number.isFinite(bookmarkId) || busy) return; - closeMenu(); setConfirmOpen(true); }; @@ -183,111 +98,72 @@ export function BookmarkPlaceRow({ onClick={onOpenDetail} onThumbnailClick={onOpenDetail} /> -
- - - {menuOpen && - typeof document !== "undefined" && - createPortal( -
- {menuPhase === "main" ? ( - <> - - - - ) : ( - <> - -
- {otherCategories.length === 0 ? ( -

- 이동할 다른 카테고리가 없습니다. -

- ) : ( - otherCategories.map((c) => ( - - )) - )} - - )} -
, - document.body, - )} + + + + {/* 폭 184는 지금 메뉴 값. 높이는 지금 상한 22rem과 화면에 남은 높이 중 작은 쪽 */} + + {menuPhase === "main" ? ( + <> + setMenuPhase("pickCategory")} + > + 카테고리 변경 + + + 삭제 + + + ) : ( + <> + setMenuPhase("main")}> + + 뒤로 + + {otherCategories.length === 0 ? ( +

+ 이동할 다른 카테고리가 없습니다. +

+ ) : ( + otherCategories.map((c) => ( + handleMoveTo(c.categoryId)} + > + + {c.name} + + )) + )} + + )} +
+
{confirmOpen ? ( - - + } className="h-5 w-2/3" /> + } tone="subtle" className="h-4 w-1/2" /> ) : ( <> diff --git a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx index e2bee4b7..11f5bfb5 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx @@ -4,6 +4,7 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNod import { MobilePlanPlaceCard } from "@/app/(main)/plan/_components/mobile/MobilePlanPlaceCard"; import { DashedBorder, DashedPlaceholderBox } from "@/components/mobile/DashedPlaceholderBox"; +import { Skeleton } from "@/components/ui/skeleton"; import type { PlanPlace } from "@/lib/plan/types"; import { cn } from "@/lib/utils"; @@ -159,12 +160,12 @@ export function MobileRouteExpandedShell({ /** 처음 불러오는 동안 — 카드와 같은 자리를 잡는다 */ export function MobileRoutePlaceCardSkeleton() { return ( - - + + } className="size-6 shrink-0 rounded-[5.5px]" /> - - - + } className="h-5 w-2/3" /> + } tone="subtle" className="h-4 w-1/2" /> + } tone="subtle" className="mt-3 h-4 w-5/6" /> ); diff --git a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx index b42911e3..dd5fd488 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx @@ -6,6 +6,8 @@ import { useAnalyticsRoomId } from "@/hooks/useAnalyticsRoomId"; import { HeroImage, HeroSkeleton } from "@/components/place/HeroSection"; import { PlaceDetailTabs } from "@/components/place/PlaceDetailTabs"; import { usePlaceDetailData } from "@/components/place/usePlaceDetailData"; +import { Alert, AlertAction, AlertTitle } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; import { AnalyticsEvents, trackAnalyticsEvent, type AnalyticsSource } from "@/lib/analytics/track"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; import type { SearchRankBucket } from "@/lib/analytics/context"; @@ -50,15 +52,19 @@ export function MobileRoutePlaceDetail({ function renderDetail() { if (isError && !detail && active) return ( -
-

장소 정보를 불러오지 못했어요.

- +
+ + 장소 정보를 불러오지 못했어요. + + + +
); if (showSkeleton) return ( - - + } className="h-5 w-24" /> + } tone="subtle" className="h-16 w-full rounded-md" /> ); return ( diff --git a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx index 3ae9471a..c8a91c7f 100644 --- a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx +++ b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx @@ -1,7 +1,7 @@ "use client"; import { CopyCheckIcon, CopyIcon, RefreshIcon, ShareIcon } from "@/assets/icons"; -import { LoadingIndicator } from "@/components/loading/LoadingIndicator"; +import { Spinner } from "@/components/ui/spinner"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -37,7 +37,7 @@ function InviteFallback({ canIssue, inviteCode, isRoomDetailLoading, isRoomDetai isRegenerating: boolean; }>) { if (isRoomDetailLoading || isRegenerating) { - return ; + return ; } let message = "발급에 실패했어요. 아래에서 재발급을 눌러 주세요."; if (isRoomDetailError) message = "방 정보를 불러오지 못했어요."; diff --git a/src/app/(main)/member-settings/_components/JoinRequestsSection.tsx b/src/app/(main)/member-settings/_components/JoinRequestsSection.tsx index 69886680..109cc620 100644 --- a/src/app/(main)/member-settings/_components/JoinRequestsSection.tsx +++ b/src/app/(main)/member-settings/_components/JoinRequestsSection.tsx @@ -55,8 +55,7 @@ function JoinRequestCard({
diff --git a/src/app/(main)/member-settings/_components/MemberCard.tsx b/src/app/(main)/member-settings/_components/MemberCard.tsx index ba2dc313..eb2cff13 100644 --- a/src/app/(main)/member-settings/_components/MemberCard.tsx +++ b/src/app/(main)/member-settings/_components/MemberCard.tsx @@ -1,9 +1,8 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; - import { MenuDotHorizontalIcon, UserEditIcon, UserXIcon } from "@/assets/icons"; +import { Badge } from "@/components/ui/badge"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { UserAvatar } from "@/components/user/UserAvatar"; import type { RoomMemberStatus } from "@/lib/api/rooms"; import { cn } from "@/lib/utils"; @@ -51,46 +50,13 @@ export function MemberCard({ member, isViewerHost, onKick, onTransfer }: Readonl member.role !== "HOST" && !isLeft; - // 스크롤 영역(헤더 드롭다운) 안에서도 잘리지 않도록 메뉴는 body에 화면 기준 위치로 띄운다 - const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null); - const open = menuPosition !== null; - const buttonRef = useRef(null); - const menuRef = useRef(null); - - function toggleMenu() { - const rect = buttonRef.current?.getBoundingClientRect(); - setMenuPosition(open || !rect ? null : { top: rect.bottom + 4, right: window.innerWidth - rect.right }); - } - - useEffect(() => { - if (!open) return; - const close = () => setMenuPosition(null); - function handleClickOutside(e: MouseEvent) { - const target = e.target as Node; - if (!buttonRef.current?.contains(target) && !menuRef.current?.contains(target)) close(); - } - function handleEscape(e: KeyboardEvent) { - if (e.key === "Escape") close(); - } - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscape); - window.addEventListener("scroll", close, true); - window.addEventListener("resize", close); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscape); - window.removeEventListener("scroll", close, true); - window.removeEventListener("resize", close); - }; - }, [open]); - return (
@@ -99,68 +65,34 @@ export function MemberCard({ member, isViewerHost, onKick, onTransfer }: Readonl {member.isCurrentUser ? " (나)" : ""}
- + {member.role === "HOST" ? "방장" : "참여자"} - +
{canAct && ( -
- - - {menuPosition && createPortal( -
- - -
, - document.body, - )} -
+ + {/* 지금 메뉴 폭 w-60을 유지한다 */} + + onTransfer(member.id)}> + + 방장 위임 + + onKick(member.id)}> + + 강제 퇴장 + + + )}
); diff --git a/src/app/(main)/member-settings/_components/RoomMembersSection.tsx b/src/app/(main)/member-settings/_components/RoomMembersSection.tsx index 36c2bac3..386cc879 100644 --- a/src/app/(main)/member-settings/_components/RoomMembersSection.tsx +++ b/src/app/(main)/member-settings/_components/RoomMembersSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { LoadingIndicator } from "@/components/loading/LoadingIndicator"; +import { Spinner } from "@/components/ui/spinner"; import { MainPageHeader } from "@/components/layout/MainPageHeader"; import { AddMemberPanel } from "./AddMemberPanel"; import { JoinRequestsSection } from "./JoinRequestsSection"; @@ -54,7 +54,7 @@ export function RoomMembersSection() { ) : null} {isMembersLoading ? ( - + ) : ( )} diff --git a/src/app/(main)/member-settings/_components/useMemberActions.tsx b/src/app/(main)/member-settings/_components/useMemberActions.tsx index 6b00a6c7..b867ce2f 100644 --- a/src/app/(main)/member-settings/_components/useMemberActions.tsx +++ b/src/app/(main)/member-settings/_components/useMemberActions.tsx @@ -5,8 +5,15 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; -import { alertDialogButtonClass } from "@/components/settings/SettingsActionButton"; -import { SettingsDialog } from "@/components/settings/SettingsDialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { useCurrentRoomMembership } from "@/hooks/useCurrentRoomMembership"; import { showRoomLeftToast } from "@/lib/stomp/forced-room-exit-dispatch"; import { useKickMember, useLeaveRoom, useTransferHost } from "@/hooks/useRooms"; @@ -32,12 +39,25 @@ function NoticeDialog({ onClose: () => void; }>) { return ( - -

{description}

- -
+ { + if (!open) onClose(); + }} + > + {/* 지금처럼 딤을 눌러도 닫힌다 */} + + + {title} + {description} + + + {/* AlertDialogAction은 눌러도 저절로 닫히지 않는다 */} + {actionLabel} + + + ); } @@ -97,7 +117,6 @@ export function useMemberActions() { <> {leaveDialog === "confirm" ? ( { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - const { data: bookmarkRows, isPending: bookmarksLoading, @@ -148,7 +149,6 @@ export function AddFromBookmarkModal({ useCreateScheduleItem(); const busy = isCreatingItem || addingGooglePlaceId != null; - const sheetDrag = useSheetDrag(onClose, busy); const handleAddPlace = useCallback( async (googlePlaceId: string) => { @@ -224,52 +224,29 @@ export function AddFromBookmarkModal({ ); return ( -
{ + if (!open) onClose(); + }} + closeDisabled={busy} > - {/* 딤은 시트와 따로 둬서 기준 시트처럼 딤만 서서히 나타나고 시트는 아래에서 올라온다 */} -
-
e.stopPropagation()} - className={cn( - "relative flex max-h-[min(85vh,560px)] w-full flex-col overflow-hidden", - // PC는 가운데 모달, 모바일 기기는 화면 폭과 관계없이 바텀시트 - "mx-auto max-w-md rounded-2xl border border-gray-border bg-white shadow-lg", - "mobile:max-w-none mobile:rounded-b-none mobile:rounded-t-[20px] mobile:border-x-0 mobile:border-b-0 mobile:shadow-[0_-8px_32px_-8px_rgba(0,0,0,0.12)]", - "mobile:animate-in mobile:slide-in-from-bottom mobile:duration-200", - "pb-[max(env(safe-area-inset-bottom,0px),12px)]", - )} + {/* 높이 상한은 지금 값. 폴더 칩·제목은 고정하고 장소 목록만 스크롤한다. 지금 PC 모달에는 닫기 X가 없다(아래 닫기 버튼) */} + - -
- -
-

- 북마크에서 추가 -

-

- 북마크 장소를 이 일차에 추가해요. -

-
+ + 북마크에서 추가 + 북마크 장소를 이 일차에 추가해요. + - + {categoriesLoading ? ( -
+
) : categoriesError ? (

@@ -293,7 +270,8 @@ export function AddFromBookmarkModal({

) : ( <> -
+ {/* 칩 줄과 목록은 지금처럼 표면 끝까지 닿는다. 표면(Dialog p-5·시트 Body px-5)의 좌우 20px만큼 넓힌다 */} +
{categories.map((c) => { const selected = selectedCategoryId === c.categoryId; @@ -322,7 +300,7 @@ export function AddFromBookmarkModal({
-
+
{bookmarksLoading ? ( ) : bookmarksError ? ( @@ -377,17 +355,12 @@ export function AddFromBookmarkModal({ )} -
- -
-
-
+ + + + }>닫기 + + + ); } diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.menu.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.menu.test.tsx new file mode 100644 index 00000000..9808da82 --- /dev/null +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.menu.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { PlanDaySection } from "./PlanDaySection"; + +let host: HTMLDivElement; +let root: Root; +const outer = vi.fn(); +const addDay = vi.fn(); +const hostEvents = { onClick: outer, onMouseDown: outer, onPointerDown: outer, onTouchStart: outer }; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); +}); + +it("keeps menu trigger and portal actions out of the day host while retaining keyboard controls", async () => { + await act(async () => root.render( +
+ +
, + )); + const trigger = document.querySelector('[aria-label="일차 메뉴"]')!; + for (const type of ["mousedown", "pointerdown", "touchstart"]) { + await act(async () => trigger.dispatchEvent(new MouseEvent(type, { bubbles: true }))); + } + await act(async () => trigger.click()); + expect(outer).not.toHaveBeenCalled(); + const item = document.querySelector('[role="menuitem"]')!; + expect(item).not.toBeNull(); + for (const type of ["mousedown", "pointerdown", "touchstart"]) { + await act(async () => item.dispatchEvent(new MouseEvent(type, { bubbles: true }))); + } + await act(async () => item.click()); + expect(addDay).toHaveBeenCalledOnce(); + expect(outer).not.toHaveBeenCalled(); + await act(async () => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }))); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(document.querySelector('[aria-controls$="-panel"]')?.getAttribute("aria-expanded")).toBe("true"); +}); diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.route-switch.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.route-switch.test.tsx new file mode 100644 index 00000000..717c8d81 --- /dev/null +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.route-switch.test.tsx @@ -0,0 +1,30 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vitest"; +import { PlanDaySection } from "./PlanDaySection"; +import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +it("label and switch change route visibility without collapsing the day", async () => { + usePlanScheduleRouteVisibilityStore.setState({ visibleByScheduleId: {}, visibilityOrder: [] }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + await act(async () => root.render(장소)); + const heading = host.querySelector('button[aria-expanded]')!; + const toggle = host.querySelector('[role="switch"]')!; + await act(async () => host.querySelector("label")!.click()); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(usePlanScheduleRouteVisibilityStore.getState().visibleByScheduleId[10]).toBe(false); + expect(heading.getAttribute("aria-expanded")).toBe("true"); + await act(async () => toggle.click()); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(heading.getAttribute("aria-expanded")).toBe("true"); + } finally { + await act(async () => root.unmount()); + host.remove(); + } +}); diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx index b13f43ff..eed0eb6c 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx @@ -1,22 +1,23 @@ "use client"; +import { MenuDotHorizontalIcon, PlusIcon, TrashIcon } from "@/assets/icons"; import { ExpenseIcon } from "@/components/icons/ExpenseIcon"; -import { useOnClickOutside } from "@/hooks/useOnClickOutside"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Switch } from "@/components/ui/switch"; import { MAIN_CARD_INNER_PADDING_X_CLASS } from "@/lib/layout-tokens"; import { usePlanItineraryExpandedStore } from "@/stores/plan-itinerary-expanded-store"; import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; import { usePlanItemCrossDayDragStore } from "@/stores/plan-item-cross-day-drag-store"; import { cn } from "@/lib/utils"; -import { - ChevronDown, - ChevronUp, - GripVertical, - MoreHorizontal, - Plus, - Trash2, -} from "lucide-react"; -import { useCallback, useId, useRef, useState } from "react"; -import type { CSSProperties, DragEvent, ReactNode, Ref } from "react"; +import { ChevronDown, ChevronUp, GripVertical } from "lucide-react"; +import { useCallback, useId, useState } from "react"; +import type { CSSProperties, DragEvent, ReactNode, Ref, SyntheticEvent } from "react"; export type PlanDaySectionDragHandleProps = { dragDisabled?: boolean; @@ -61,6 +62,16 @@ export type PlanDaySectionProps = { }; }; +// 메뉴는 body로 포털되지만 React 이벤트는 트리를 따라 섹션(일차 간 드롭 핸들러)으로 올라간다. +// 트리거와 포털 표면에서만 포인터 전파를 끊고, 키보드는 Base UI에 전달한다 +const stopPropagation = (event: SyntheticEvent) => event.stopPropagation(); +const pointerBoundary = { + onClick: stopPropagation, + onMouseDown: stopPropagation, + onPointerDown: stopPropagation, + onTouchStart: stopPropagation, +}; + export function PlanDaySection({ title, subtitle, @@ -119,10 +130,6 @@ export function PlanDaySection({ const setRouteVisible = usePlanScheduleRouteVisibilityStore( (s) => s.setRouteVisible, ); - const toggleRouteVisible = useCallback(() => { - if (trackedSid === undefined) return; - setRouteVisible(trackedSid, !routeVisible); - }, [trackedSid, routeVisible, setRouteVisible]); const toggle = useCallback(() => { if (trackedSid !== undefined) { @@ -151,8 +158,6 @@ export function PlanDaySection({ onRequestAddExpense != null || onRequestDeleteSchedule != null || onRequestInsertScheduleAfter != null; const [menuOpen, setMenuOpen] = useState(false); - const menuRef = useRef(null); - useOnClickOutside(menuRef, () => setMenuOpen(false)); return (
{subtitle ? ( - // 디자인 시스템 Badge / Weak / Brand / md — 배경색은 시맨틱 토큰이 없어 원시 토큰을 쓴다 - + {title} - + ) : null}
@@ -229,88 +233,45 @@ export function PlanDaySection({ {showScheduleMenu ? ( -
- - {menuOpen && !isScheduleMenuDisabled ? ( -
- {onRequestAddExpense ? ( - - ) : null} - {onRequestInsertScheduleAfter ? ( - - ) : null} - {onRequestDeleteSchedule ? ( - - ) : null} -
- ) : null} -
+ + + + {onRequestAddExpense ? ( + + + {title} 비용 추가 + + ) : null} + {onRequestInsertScheduleAfter ? ( + + + 일차 추가 + + ) : null} + {onRequestDeleteSchedule ? ( + + + 일차 삭제 + + ) : null} + + ) : null}
{summaryAction || trackedSid !== undefined ? ( @@ -320,27 +281,16 @@ export function PlanDaySection({ )}>
{summaryAction}
{trackedSid !== undefined ? ( - + setRouteVisible(trackedSid, checked)} + /> + ) : null}
) : null} diff --git a/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx b/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx index cadd1c3f..b80310a2 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx @@ -5,6 +5,9 @@ import { MemoIcon } from "@/components/icons"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; +import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; import { useUpdateScheduleItem } from "@/hooks/useRooms"; import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens"; import { renderTextWithLinks } from "@/lib/text/renderTextWithLinks"; @@ -35,73 +38,6 @@ function isLocallyDirty(draft: string, editStartMemo: string): boolean { return draft !== editStartMemo; } -function MemoOverwriteConfirmDialog({ - open, - isPending, - onCancel, - onConfirm, -}: { - open: boolean; - isPending: boolean; - onCancel: () => void; - onConfirm: () => void; -}) { - useEffect(() => { - if (!open) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onCancel(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [open, onCancel]); - - if (!open) return null; - - return ( -
{ - if (ev.target === ev.currentTarget) onCancel(); - }} - > -
e.stopPropagation()} - className="w-full max-w-sm rounded-2xl border border-gray-border bg-white p-5 shadow-lg" - > -

- 다른 사람의 입력을 덮어씁니다! -

-

- 다른 멤버가 메모를 수정했어요. 저장하면 그 내용 대신 지금 작성 중인 메모로 바뀝니다. -

-
- - -
-
-
- ); -} - export function PlanItemMemoReadOnly({ memo, onDelete, @@ -224,24 +160,25 @@ export function PlanItemMemoEditor({
-