Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0422eef
fix(auth): 세션 확인 API에서 백엔드 장애와 인증 실패 구분
KangShinGyu98 Oct 25, 2026
b8b8c78
fix(stomp): 재연결을 stompjs 지수 백오프에 맡기고 수동 루프 제거
KangShinGyu98 Oct 25, 2026
7e1a108
fix(stomp): 네트워크가 돌아오면 재연결 예약을 기다리지 않고 바로 연결
KangShinGyu98 Oct 25, 2026
f67b9d1
fix(stomp): 끊긴 동안 권한을 잃은 방은 재연결 뒤 퇴장
KangShinGyu98 Oct 25, 2026
bd1dd7a
fix(rooms): 화면 밖에서 무효화된 일정·멤버·안읽음 배지를 다시 볼 때 재조회
KangShinGyu98 Oct 25, 2026
8fc9e9a
fix(plan): 일정 전체 재조회를 한 조회로 모아 늦은 응답이 새 일정을 덮지 않게
KangShinGyu98 Oct 25, 2026
b28a415
fix(stomp): 방 토픽을 구독할 때마다 놓친 방 데이터 다시 받기
KangShinGyu98 Oct 25, 2026
1d87aba
fix(ui): 재조회가 실패해도 보이던 일정·북마크 목록 유지
KangShinGyu98 Oct 25, 2026
4491236
fix(stomp): 멤버·접속 상태 패치가 다시 받을 멤버 목록의 재조회를 지우지 않게
KangShinGyu98 Oct 25, 2026
a6de6f2
fix(plan): 드래그 중 일정이 바뀌면 순서를 저장하지 않고 알림
KangShinGyu98 Oct 25, 2026
57a2931
fix(plan): 연결이 끊긴 동안과 놓친 변경을 다시 받는 동안 일정 드래그 잠금
KangShinGyu98 Oct 25, 2026
7b9c66a
fix(chat): 연결이 바뀌어도 읽음 처리 함수를 그대로 유지
KangShinGyu98 Oct 25, 2026
e5f165d
fix(chat): 실시간 채팅을 sequence 커서로 붙이고 놓친 메시지를 끝까지 따라잡기
KangShinGyu98 Oct 25, 2026
aca3095
fix(chat): 거의 동시에 보낸 채팅이 보낸 순서대로 보이게 정렬
KangShinGyu98 Oct 25, 2026
715a433
test(chat): 놓친 채팅 페이지 테스트의 기대 개수 수정
KangShinGyu98 Oct 26, 2026
ccade96
fix(plan): 드래그 중 일정이 바뀌어도 놓은 자리로 순서 저장
KangShinGyu98 Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -222,7 +223,8 @@ export function BookmarkFoldersView() {
);
}

if (isError) {
// 재조회 실패는 보이던 폴더를 지우지 않는다 — 처음 불러오기에 실패했을 때만 오류 화면
if (isLoadingError) {
return (
<div className="space-y-2.5">
<MainPageHeader title="북마크" />
Expand Down
5 changes: 3 additions & 2 deletions src/app/(main)/map/_components/useMobileRouteView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -425,7 +426,7 @@ export function useMobileRouteView({
return null;
} else if (schedulesQuery.isLoading || (schedules.length > 0 && entries.length === 0)) {
return <RouteCardSlot><MobileRoutePlaceCardSkeleton /></RouteCardSlot>;
} else if (schedulesQuery.isError) {
} else if (schedulesQuery.isLoadingError) {
return (
<RouteCardSlot>
<MobileRouteMessageCard tone="error" title="일정을 불러오지 못했어요" description="잠시 후 다시 시도해 주세요." />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function AddFromBookmarkModal({
const {
data: categories,
isPending: categoriesLoading,
isError: categoriesError,
isLoadingError: categoriesError,
error: categoriesErr,
refetch: refetchCategories,
} = useBookmarkCategories(roomId);
Expand Down Expand Up @@ -82,7 +82,7 @@ export function AddFromBookmarkModal({
const {
data: bookmarkRows,
isPending: bookmarksLoading,
isError: bookmarksError,
isLoadingError: bookmarksError,
error: bookmarksErr,
refetch: refetchBookmarks,
} = useRoomBookmarks(roomId, selectedCategoryId);
Expand Down
4 changes: 3 additions & 1 deletion src/app/(main)/plan/_components/itinerary/PlanPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function PlanPageView() {
data: schedules,
isPending,
isError,
isLoadingError,
} = useRoomSchedules(roomIdForQueries);
const { mutate: deleteSchedule, isPending: isDeletingSchedule } =
useDeleteRoomSchedule();
Expand Down Expand Up @@ -200,7 +201,8 @@ export function PlanPageView() {
roomId={roomId}
schedules={sortedSchedules}
isLoading={showInitialLoading}
isError={isError}
// 재조회 실패는 보이던 일정을 지우지 않는다 — 처음 불러오기에 실패했을 때만 오류로 바꾼다
isError={isLoadingError}
menuDisabled={isCreatingSchedule || isDeletingSchedule || isMovePending}
onRequestDeleteDay={handleDeleteScheduleDay}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
18 changes: 15 additions & 3 deletions src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<HTMLElement>) => {
Expand Down
47 changes: 33 additions & 14 deletions src/contexts/stomp/StompProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
evictRoomFromClientCaches,
refreshRoomsList,
showForcedExitToast,
showRoomAccessLostToast,
showRoomLeftToast,
} from "@/lib/stomp/forced-room-exit-dispatch";
import {
Expand All @@ -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";
Expand Down Expand Up @@ -91,6 +92,7 @@ export function StompProvider({ children }: { children: ReactNode }) {
const lastSubscribedRoomIdRef = useRef<string | null>(null);
const forcedExitConsumedRef = useRef(false);
const suppressCloseRecoveryRef = useRef(false);
const retryConnectionRef = useRef<(() => void) | null>(null);
const [connectionState, setConnectionState] = useState<StompConnectionState>({
client: null,
connected: false,
Expand All @@ -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?.();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -237,6 +252,7 @@ export function StompProvider({ children }: { children: ReactNode }) {
stompEligible,
suppressCloseRecoveryRef,
notifyForcedRoomExit,
notifyRoomAccessLost,
subscribeToRoomTopics,
invalidateRoomTopics,
teardownConnectedClient,
Expand All @@ -247,8 +263,11 @@ export function StompProvider({ children }: { children: ReactNode }) {
forcedExitConsumedRef,
queryClientRef,
setConnectionState,
retryConnectionRef,
});

useStompOnlineRetryEffect(retryConnectionRef);

const roomTopicsDeferred = pathDefersRoomStompRoomTopics(pathname);

useStompRoomTopicsResyncEffect({
Expand Down
Loading
Loading