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/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..f8a75b9b 100644
--- a/src/contexts/stomp/use-stomp-provider-effects.test.tsx
+++ b/src/contexts/stomp/use-stomp-provider-effects.test.tsx
@@ -2,10 +2,16 @@
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_DELAY_MS,
+ 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 +19,16 @@ 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_DELAY_MS: 5_000,
+}));
+vi.mock("@/lib/rooms", () => ({
+ validateRoomAccess: mocks.validate,
}));
vi.mock("@/lib/stomp/sessionPresencePing", () => ({
startSessionPresencePing: mocks.ping,
@@ -25,30 +37,56 @@ 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 = 5_000;
+
+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 +94,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 +122,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 +134,192 @@ 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);
+});
+
+it("shows the connection banner only after staying disconnected and clears it on connect", 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(STOMP_CONNECTION_ISSUE_DELAY_MS / 2);
+ client.onWebSocketClose(ABNORMAL_CLOSE);
+ await vi.advanceTimersByTimeAsync(STOMP_CONNECTION_ISSUE_DELAY_MS / 2 - 1);
+ expect(useStompConnectionStore.getState().connectionIssue).toBeNull();
+
+ await vi.advanceTimersByTimeAsync(1);
+ expect(useStompConnectionStore.getState().connectionIssue).toBe(
+ STOMP_CONNECTION_ISSUE_MESSAGE,
+ );
+
+ client.onConnect(CONNECTED_FRAME);
+ expect(useStompConnectionStore.getState().connectionIssue).toBeNull();
+ await hook.unmount();
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
+it("does not show the banner when the connection recovers in time", 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(1_000);
+ client.onConnect(CONNECTED_FRAME);
+ await vi.advanceTimersByTimeAsync(STOMP_CONNECTION_ISSUE_DELAY_MS);
+
+ expect(useStompConnectionStore.getState().connectionIssue).toBeNull();
+ await hook.unmount();
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
+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("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..ae77e4e8 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_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_DELAY_MS,
+ 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>;
+ /** 연결 문제 배너의 '다시 시도'가 부르는 즉시 재연결 */
+ 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,74 @@ export function useStompClientLifecycleEffect({
const stopPingRef: { current: (() => void) | null } = { current: null };
let effectActive = true;
+ // 첫 연결은 proxy가 세션을 확인했으므로, 한 번이라도 끊긴 뒤의 시도에서만 세션을 갱신한다
+ let connectionDropped = false;
+ // 연결 시도(세션 확인~CONNECTED·close) 중에 다시 시도하면 연결이 둘로 갈라진다
+ let attemptInFlight = false;
+ let connectionIssueTimer: ReturnType | null = null;
+ // 서버는 구독 거부 등으로 ERROR를 보낸 뒤 연결을 닫는다(Spring 기본 동작)
+ let closingAfterStompError = false;
+ let stompErrorReconnectTimer: ReturnType | null = null;
+ let accessCheckRoomId: string | null = null;
const stopAppPing = () => {
stopPingRef.current?.();
stopPingRef.current = null;
};
+ const clearConnectionIssueTimer = () => {
+ if (connectionIssueTimer === null) return;
+ clearTimeout(connectionIssueTimer);
+ connectionIssueTimer = 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;
+ await refreshSessionBeforeReconnect({
client,
queryClient: queryClientRef.current,
- isSuppressed,
isActive,
});
};
+ retryConnectionRef.current = () => {
+ if (!isActive() || attemptInFlight || client.connected) return;
+ clearStompErrorReconnectTimer();
+ // deactivate→activate는 예약된 재연결을 지우고 간격을 처음 값으로 되돌린 뒤 바로 시도한다
+ void client.deactivate().then(() => {
+ if (isActive()) client.activate();
+ });
+ };
+
client.onConnect = () => {
if (!effectActive) return;
+ attemptInFlight = false;
+ closingAfterStompError = false;
+ clearConnectionIssueTimer();
useStompConnectionStore.getState().clearConnectionIssue();
setConnectionState({ client, connected: true });
forcedExitConsumedRef.current = false;
@@ -121,31 +174,59 @@ 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();
+
+ // stompjs는 CONNECTED를 받을 때마다 간격을 처음 값으로 되돌려, 구독 오류로 닫히는 연결은 1초마다 다시 붙는다.
+ // 이 경우만 라이브러리 재연결을 멈추고 상한 간격 뒤에 다시 연결한다(문서 밖 보강)
+ if (closingAfterStompError) {
+ closingAfterStompError = false;
+ void client.deactivate();
+ clearStompErrorReconnectTimer();
+ stompErrorReconnectTimer = setTimeout(() => {
+ stompErrorReconnectTimer = null;
+ if (isActive()) client.activate();
+ }, STOMP_MAX_RECONNECT_DELAY_MS);
+ }
+
+ // 끊긴 상태가 이어질 때만 배너를 띄운다
+ if (connectionIssueTimer === null) {
+ connectionIssueTimer = setTimeout(() => {
+ connectionIssueTimer = null;
+ if (!isActive() || client.connected) return;
+ useStompConnectionStore
+ .getState()
+ .setConnectionIssue(STOMP_CONNECTION_ISSUE_MESSAGE);
+ }, STOMP_CONNECTION_ISSUE_DELAY_MS);
+ }
};
client.activate();
return () => {
effectActive = false;
+ retryConnectionRef.current = null;
+ clearConnectionIssueTimer();
+ clearStompErrorReconnectTimer();
stopAppPing();
teardownConnectedClient();
};
@@ -154,11 +235,13 @@ export function useStompClientLifecycleEffect({
forcedExitConsumedRef,
pathnameRef,
queryClientRef,
+ retryConnectionRef,
setConnectionState,
userRoomsQueueUnsubRef,
getResolvedRoomId,
invalidateRoomTopics,
notifyForcedRoomExit,
+ notifyRoomAccessLost,
stompEligible,
subscribeToRoomTopics,
suppressCloseRecoveryRef,
@@ -166,6 +249,17 @@ export function useStompClientLifecycleEffect({
]);
}
+/** 브라우저가 네트워크 복귀를 알리면 다음 재연결 예약(최대 5초)을 기다리지 않고 바로 다시 연결합니다. */
+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