From 0422eef0c6c63fdf7fba1a36d84745d787fe209c Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 18:11:09 +0900 Subject: [PATCH 01/16] =?UTF-8?q?fix(auth):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20API=EC=97=90=EC=84=9C=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=A5=EC=95=A0=EC=99=80=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드 도달 실패·5xx·429는 503, 인증 실패가 확정된 경우만 401 - refresh 직후 재확인이 장애로 실패해도 새 쿠키 전달 - proxy의 세션 판정은 그대로 유지 Co-Authored-By: Claude Opus 5.5 --- src/lib/auth-server.test.ts | 171 ++++++++++++++++++++++++++++++++++++ src/lib/auth-server.ts | 68 ++++++++++---- 2 files changed, 222 insertions(+), 17 deletions(-) create mode 100644 src/lib/auth-server.test.ts 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 }, From b8b8c780359d2dc1b13aad1bbbd6c0d7060b5934 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 18:15:57 +0900 Subject: [PATCH 02/16] =?UTF-8?q?fix(stomp):=20=EC=9E=AC=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EC=9D=84=20stompjs=20=EC=A7=80=EC=88=98=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=84=EC=97=90=20=EB=A7=A1=EA=B8=B0=EA=B3=A0=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EB=A3=A8=ED=94=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reconnectDelay·EXPONENTIAL·maxReconnectDelay(5초)로 재시도 간격 위임 - 재연결 직전 세션 확인을 beforeConnect로 옮기고 401이면 재연결 중단 후 로그아웃 - 연결 문제 배너는 끊긴 지 45초가 지나면 표시하고 연결되면 해제 - 다시 시도는 연결 시도 중이 아닐 때만 바로 재연결 Co-Authored-By: Claude Opus 5.5 --- src/contexts/stomp/StompProvider.tsx | 18 +- .../stomp/use-stomp-provider-effects.test.tsx | 175 +++++++++++++++--- .../stomp/use-stomp-provider-effects.tsx | 77 +++++--- src/lib/stomp/client.test.ts | 18 ++ src/lib/stomp/client.ts | 11 +- src/lib/stomp/stomp-session-recovery.test.ts | 104 +++++++++++ src/lib/stomp/stomp-session-recovery.ts | 107 +++-------- src/stores/stomp-connection-store.ts | 19 +- 8 files changed, 375 insertions(+), 154 deletions(-) create mode 100644 src/lib/stomp/client.test.ts create mode 100644 src/lib/stomp/stomp-session-recovery.test.ts diff --git a/src/contexts/stomp/StompProvider.tsx b/src/contexts/stomp/StompProvider.tsx index b4bcc050..d89f07a4 100644 --- a/src/contexts/stomp/StompProvider.tsx +++ b/src/contexts/stomp/StompProvider.tsx @@ -34,7 +34,6 @@ 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 { @@ -91,6 +90,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 +102,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?.(); @@ -247,6 +236,7 @@ export function StompProvider({ children }: { children: ReactNode }) { forcedExitConsumedRef, queryClientRef, setConnectionState, + retryConnectionRef, }); const roomTopicsDeferred = pathDefersRoomStompRoomTopics(pathname); diff --git a/src/contexts/stomp/use-stomp-provider-effects.test.tsx b/src/contexts/stomp/use-stomp-provider-effects.test.tsx index 9a3fde0e..cc07540f 100644 --- a/src/contexts/stomp/use-stomp-provider-effects.test.tsx +++ b/src/contexts/stomp/use-stomp-provider-effects.test.tsx @@ -2,8 +2,13 @@ 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, useStompRoomTopicsResyncEffect, @@ -13,6 +18,7 @@ const mocks = vi.hoisted(() => ({ ping: vi.fn(), queue: vi.fn(), stop: vi.fn(), + refresh: vi.fn(), })); vi.mock("@/lib/stomp/client", () => ({ createStompClient: mocks.create, @@ -25,30 +31,51 @@ 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 }; + +type FakeClient = { + activate: ReturnType; + deactivate: ReturnType; + connected: boolean; + beforeConnect: () => Promise; + onConnect: (frame: typeof CONNECTED_FRAME) => 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: () => {}, 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, + 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 +83,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 +111,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 +123,103 @@ 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(); +}); diff --git a/src/contexts/stomp/use-stomp-provider-effects.tsx b/src/contexts/stomp/use-stomp-provider-effects.tsx index 7626adbc..4f4b8723 100644 --- a/src/contexts/stomp/use-stomp-provider-effects.tsx +++ b/src/contexts/stomp/use-stomp-provider-effects.tsx @@ -9,13 +9,17 @@ import type { Client } from "@stomp/stompjs"; import { createStompClient, getStompBrokerURL } 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; @@ -40,10 +44,13 @@ type LifecycleOpts = { forcedExitConsumedRef: MutableRefObject; queryClientRef: MutableRefObject; setConnectionState: Dispatch>; + /** 연결 문제 배너의 '다시 시도'가 부르는 즉시 재연결 */ + retryConnectionRef: MutableRefObject<(() => void) | null>; }; /** * 로그인·경로 조건으로 STOMP 클라이언트 1개를 유지하고, 재연결 시 유저 방 큐 구독을 세팅합니다. + * 재연결 간격은 stompjs(`createStompClient`)가 맡고, 이 effect는 재연결 직전 세션 갱신과 연결 상태 표시만 합니다. */ export function useStompClientLifecycleEffect({ stompEligible, @@ -59,6 +66,7 @@ export function useStompClientLifecycleEffect({ forcedExitConsumedRef, queryClientRef, setConnectionState, + retryConnectionRef, }: LifecycleOpts): void { useEffect(() => { if (!stompEligible) { @@ -72,36 +80,51 @@ 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; const stopAppPing = () => { stopPingRef.current?.(); stopPingRef.current = null; }; + const clearConnectionIssueTimer = () => { + if (connectionIssueTimer === null) return; + clearTimeout(connectionIssueTimer); + connectionIssueTimer = null; + }; + const client = createStompClient(getStompBrokerURL()); clientRef.current = client; setConnectionState({ client, connected: false }); - const isActive = () => effectActive; - const isSuppressed = () => suppressCloseRecoveryRef.current; - - const requestStompRecovery = () => { - if (!effectActive || suppressCloseRecoveryRef.current) return; - - stopAppPing(); - setConnectionState((prev) => ({ ...prev, connected: false })); - invalidateRoomTopics(); + const isActive = () => effectActive && !suppressCloseRecoveryRef.current; - 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; + // deactivate→activate는 예약된 재연결을 지우고 간격을 처음 값으로 되돌린 뒤 바로 시도한다 + void client.deactivate().then(() => { + if (isActive()) client.activate(); + }); + }; + client.onConnect = () => { if (!effectActive) return; + attemptInFlight = false; + clearConnectionIssueTimer(); useStompConnectionStore.getState().clearConnectionIssue(); setConnectionState({ client, connected: true }); forcedExitConsumedRef.current = false; @@ -121,31 +144,42 @@ export function useStompClientLifecycleEffect({ } }; - client.onDisconnect = () => { - if (!effectActive) return; - requestStompRecovery(); - }; - client.onStompError = () => { if (!effectActive) return; setConnectionState((prev) => ({ ...prev, connected: false })); }; 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가 예약한다. 여기서는 끊긴 상태가 이어질 때만 배너를 띄운다 + 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(); stopAppPing(); teardownConnectedClient(); }; @@ -154,6 +188,7 @@ export function useStompClientLifecycleEffect({ forcedExitConsumedRef, pathnameRef, queryClientRef, + retryConnectionRef, setConnectionState, userRoomsQueueUnsubRef, getResolvedRoomId, 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..e1711e89 100644 --- a/src/lib/stomp/client.ts +++ b/src/lib/stomp/client.ts @@ -1,8 +1,11 @@ -import { Client } from "@stomp/stompjs"; +import { Client, ReconnectionTimeMode } from "@stomp/stompjs"; import SockJS from "sockjs-client"; import { clientEnv } from "@/lib/client-env"; +/** 재연결 간격 상한. 기존 오프라인 재시도 간격(세션 확인 5초)에 맞춘다 */ +export const STOMP_MAX_RECONNECT_DELAY_MS = 5_000; + /** * NEXT_PUBLIC_API_BASE_URL 기반으로 SockJS HTTP URL을 반환합니다. * 백엔드가 .withSockJS()로 설정되어 있으므로 ws:// 대신 http:// 를 사용합니다. @@ -11,9 +14,13 @@ export function getStompBrokerURL(): string { return `${clientEnv.NEXT_PUBLIC_API_BASE_URL}/ws`; } +/** 끊기면 stompjs가 지수 간격(1→2→4…초, 상한 5초)으로 다시 연결한다 */ 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/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/stores/stomp-connection-store.ts b/src/stores/stomp-connection-store.ts index 91081bb3..cd46c15a 100644 --- a/src/stores/stomp-connection-store.ts +++ b/src/stores/stomp-connection-store.ts @@ -3,32 +3,23 @@ import { create } from "zustand"; export const STOMP_CONNECTION_ISSUE_MESSAGE = "실시간 연결을 복구하지 못했어요. 네트워크를 확인한 뒤 다시 시도해 주세요."; +/** 끊긴 상태가 이만큼 이어지면 배너를 띄운다. 기존 체감(세션 확인 5초 간격 10회 실패 뒤 배너)에 맞춘다 */ +export const STOMP_CONNECTION_ISSUE_DELAY_MS = 45_000; + 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 7e1a108d4f0dc1f4709b5e33dcf0d11baf161ea6 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 18:16:37 +0900 Subject: [PATCH 03/16] =?UTF-8?q?fix(stomp):=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EA=B0=80=20=EB=8F=8C=EC=95=84=EC=98=A4=EB=A9=B4=20?= =?UTF-8?q?=EC=9E=AC=EC=97=B0=EA=B2=B0=20=EC=98=88=EC=95=BD=EC=9D=84=20?= =?UTF-8?q?=EA=B8=B0=EB=8B=A4=EB=A6=AC=EC=A7=80=20=EC=95=8A=EA=B3=A0=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 브라우저 online 이벤트에서 다시 시도와 같은 경로로 즉시 재연결 Co-Authored-By: Claude Opus 5.5 --- src/contexts/stomp/StompProvider.tsx | 3 +++ .../stomp/use-stomp-provider-effects.test.tsx | 15 +++++++++++++++ src/contexts/stomp/use-stomp-provider-effects.tsx | 11 +++++++++++ 3 files changed, 29 insertions(+) diff --git a/src/contexts/stomp/StompProvider.tsx b/src/contexts/stomp/StompProvider.tsx index d89f07a4..b22d6e96 100644 --- a/src/contexts/stomp/StompProvider.tsx +++ b/src/contexts/stomp/StompProvider.tsx @@ -38,6 +38,7 @@ import { useStompConnectionStore } from "@/stores/stomp-connection-store"; import { useStompClientLifecycleEffect, + useStompOnlineRetryEffect, useStompRoomTopicsResyncEffect, type StompConnectionState, } from "./use-stomp-provider-effects"; @@ -239,6 +240,8 @@ export function StompProvider({ children }: { children: ReactNode }) { 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 cc07540f..9073fe3b 100644 --- a/src/contexts/stomp/use-stomp-provider-effects.test.tsx +++ b/src/contexts/stomp/use-stomp-provider-effects.test.tsx @@ -11,6 +11,7 @@ import { } from "@/stores/stomp-connection-store"; import { useStompClientLifecycleEffect, + useStompOnlineRetryEffect, useStompRoomTopicsResyncEffect, } from "./use-stomp-provider-effects"; const mocks = vi.hoisted(() => ({ @@ -223,3 +224,17 @@ it("retries immediately unless a connection attempt is in flight", async () => { 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); +}); diff --git a/src/contexts/stomp/use-stomp-provider-effects.tsx b/src/contexts/stomp/use-stomp-provider-effects.tsx index 4f4b8723..c8638016 100644 --- a/src/contexts/stomp/use-stomp-provider-effects.tsx +++ b/src/contexts/stomp/use-stomp-provider-effects.tsx @@ -201,6 +201,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; From f67b9d1f12667ce19ed043d9ccca1528b291e983 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 18:19:07 +0900 Subject: [PATCH 04/16] =?UTF-8?q?fix(stomp):=20=EB=81=8A=EA=B8=B4=20?= =?UTF-8?q?=EB=8F=99=EC=95=88=20=EA=B6=8C=ED=95=9C=EC=9D=84=20=EC=9E=83?= =?UTF-8?q?=EC=9D=80=20=EB=B0=A9=EC=9D=80=20=EC=9E=AC=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EB=92=A4=20=ED=87=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구독 오류(ERROR)를 받으면 방 접근을 HTTP로 확인해 거부면 퇴장 처리 - 구독 오류로 연결이 닫히면 5초 뒤 다시 연결해 1초 간격 반복 방지 Co-Authored-By: Claude Opus 5.5 --- src/contexts/stomp/StompProvider.tsx | 26 ++++++ .../stomp/use-stomp-provider-effects.test.tsx | 85 +++++++++++++++++++ .../stomp/use-stomp-provider-effects.tsx | 52 +++++++++++- src/lib/stomp/forced-room-exit-dispatch.ts | 8 ++ 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/src/contexts/stomp/StompProvider.tsx b/src/contexts/stomp/StompProvider.tsx index b22d6e96..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 { @@ -174,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); @@ -227,6 +252,7 @@ export function StompProvider({ children }: { children: ReactNode }) { stompEligible, suppressCloseRecoveryRef, notifyForcedRoomExit, + notifyRoomAccessLost, subscribeToRoomTopics, invalidateRoomTopics, teardownConnectedClient, diff --git a/src/contexts/stomp/use-stomp-provider-effects.test.tsx b/src/contexts/stomp/use-stomp-provider-effects.test.tsx index 9073fe3b..f8a75b9b 100644 --- a/src/contexts/stomp/use-stomp-provider-effects.test.tsx +++ b/src/contexts/stomp/use-stomp-provider-effects.test.tsx @@ -20,10 +20,15 @@ const mocks = vi.hoisted(() => ({ 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, @@ -45,6 +50,8 @@ const CONNECTED_FRAME = { binaryBody: new Uint8Array(), }; const ABNORMAL_CLOSE = { code: 1006 }; +const PROTOCOL_ERROR_CLOSE = { code: 1002 }; +const RECONNECT_CAP_MS = 5_000; type FakeClient = { activate: ReturnType; @@ -52,6 +59,7 @@ type FakeClient = { connected: boolean; beforeConnect: () => Promise; onConnect: (frame: typeof CONNECTED_FRAME) => void; + onStompError: () => void; onWebSocketClose: (event: { code: number }) => void; }; @@ -62,6 +70,7 @@ function fakeClient(connected = false): FakeClient { connected, beforeConnect: () => Promise.resolve(), onConnect: () => {}, + onStompError: () => {}, onWebSocketClose: () => {}, }; } @@ -74,6 +83,7 @@ function lifecycleOpts(client: FakeClient) { stompEligible: true, suppressCloseRecoveryRef: { current: false }, notifyForcedRoomExit: vi.fn(), + notifyRoomAccessLost: vi.fn(), subscribeToRoomTopics: vi.fn(), invalidateRoomTopics: vi.fn(), teardownConnectedClient: vi.fn(), @@ -238,3 +248,78 @@ it("reconnects right away when the browser reports the network is back", async ( 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 c8638016..ae77e4e8 100644 --- a/src/contexts/stomp/use-stomp-provider-effects.tsx +++ b/src/contexts/stomp/use-stomp-provider-effects.tsx @@ -6,7 +6,12 @@ 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 { refreshSessionBeforeReconnect, @@ -34,6 +39,7 @@ type LifecycleOpts = { eventRoomId: string, message?: string, ) => void; + notifyRoomAccessLost: (roomId: string) => void; subscribeToRoomTopics: (client: Client, roomId: string) => void; invalidateRoomTopics: () => void; teardownConnectedClient: () => void; @@ -56,6 +62,7 @@ export function useStompClientLifecycleEffect({ stompEligible, suppressCloseRecoveryRef, notifyForcedRoomExit, + notifyRoomAccessLost, subscribeToRoomTopics, invalidateRoomTopics, teardownConnectedClient, @@ -85,6 +92,10 @@ export function useStompClientLifecycleEffect({ // 연결 시도(세션 확인~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?.(); @@ -97,12 +108,29 @@ export function useStompClientLifecycleEffect({ 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 && !suppressCloseRecoveryRef.current; + // 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); + }); + }; + client.beforeConnect = async () => { attemptInFlight = true; if (!connectionDropped) return; @@ -115,6 +143,7 @@ export function useStompClientLifecycleEffect({ retryConnectionRef.current = () => { if (!isActive() || attemptInFlight || client.connected) return; + clearStompErrorReconnectTimer(); // deactivate→activate는 예약된 재연결을 지우고 간격을 처음 값으로 되돌린 뒤 바로 시도한다 void client.deactivate().then(() => { if (isActive()) client.activate(); @@ -124,6 +153,7 @@ export function useStompClientLifecycleEffect({ client.onConnect = () => { if (!effectActive) return; attemptInFlight = false; + closingAfterStompError = false; clearConnectionIssueTimer(); useStompConnectionStore.getState().clearConnectionIssue(); setConnectionState({ client, connected: true }); @@ -146,7 +176,11 @@ export function useStompClientLifecycleEffect({ client.onStompError = () => { if (!effectActive) return; + closingAfterStompError = true; setConnectionState((prev) => ({ ...prev, connected: false })); + // 끊긴 동안 강퇴·방 삭제 알림을 놓친 방에 다시 구독하면 서버가 ERROR로 거부한다 + const rid = getResolvedRoomId()?.trim(); + if (rid) confirmRoomAccess(rid); }; client.onWebSocketClose = (event) => { @@ -162,7 +196,19 @@ export function useStompClientLifecycleEffect({ setConnectionState((prev) => ({ ...prev, connected: false })); invalidateRoomTopics(); - // 재연결은 stompjs가 예약한다. 여기서는 끊긴 상태가 이어질 때만 배너를 띄운다 + // 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; @@ -180,6 +226,7 @@ export function useStompClientLifecycleEffect({ effectActive = false; retryConnectionRef.current = null; clearConnectionIssueTimer(); + clearStompErrorReconnectTimer(); stopAppPing(); teardownConnectedClient(); }; @@ -194,6 +241,7 @@ export function useStompClientLifecycleEffect({ getResolvedRoomId, invalidateRoomTopics, notifyForcedRoomExit, + notifyRoomAccessLost, stompEligible, subscribeToRoomTopics, suppressCloseRecoveryRef, 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, From bd1dd7a02809353425d21303385406ce40c8c7f7 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:20:52 +0900 Subject: [PATCH 05/16] =?UTF-8?q?fix(rooms):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=96=EC=97=90=EC=84=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94?= =?UTF-8?q?=EB=90=9C=20=EC=9D=BC=EC=A0=95=C2=B7=EB=A9=A4=EB=B2=84=C2=B7?= =?UTF-8?q?=EC=95=88=EC=9D=BD=EC=9D=8C=20=EB=B0=B0=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=B3=BC=20=EB=95=8C=20=EC=9E=AC=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refetchOnMount: false 제거, staleTime: Infinity는 유지해 무효화된 경우에만 조회 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useRoomSchedules.test.tsx | 47 +++++++++++++++++++++++++++++ src/hooks/useRoomUnreadCount.ts | 1 - src/hooks/useRooms.ts | 2 -- 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useRoomSchedules.test.tsx 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.ts b/src/hooks/useRooms.ts index f6668bc8..15da9a28 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -167,7 +167,6 @@ export function useRoomSchedules(roomId: string | null) { queryFn: () => hydrateRoomSchedulesFromServer(queryClient, id), enabled: id.length > 0, staleTime: Infinity, - refetchOnMount: false, refetchOnWindowFocus: false, }); } @@ -613,7 +612,6 @@ export function useRoomMembers(roomId: string | null) { queryFn: () => getRoomMembers(roomId!), enabled: !!roomId && (!waitForStompBeforeMembers || stompConnected), staleTime: Infinity, - refetchOnMount: false, refetchOnWindowFocus: false, }); } From 8fc9e9aeda957be24374d655c94010b132da1ead Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:30:17 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix(plan):=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=ED=95=9C=20=EC=A1=B0=ED=9A=8C=EB=A1=9C=20=EB=AA=A8=EC=95=84=20?= =?UTF-8?q?=EB=8A=A6=EC=9D=80=20=EC=9D=91=EB=8B=B5=EC=9D=B4=20=EC=83=88=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=EC=9D=84=20=EB=8D=AE=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 취소된 일정 조회는 캐시에 쓰지 않고, 미리보기를 기다린 뒤 한 번에 쓰기 - 본인 일차 변경·여행 날짜 변경·ROOM_SCHEDULES_RESYNCED는 room-schedules 조회를 거쳐 재조회 - 일차 목록을 고치는 동안 전체 재조회가 진행 중이면 다시 시작 - 재조회로 순서가 바뀐 일차는 이웃이 바뀐 구간 경로만 무효화 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useExpenseInvalidation.test.ts | 6 +- src/hooks/useRooms.analytics.test.tsx | 8 +- src/hooks/useRooms.ts | 19 ++-- src/lib/api/rooms/schedules.ts | 3 +- 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/rooms.hydrate.test.ts | 98 ++++++++++++++++++++ src/lib/rooms.ts | 52 ++++++++--- src/lib/stomp/schedules-dispatch.test.ts | 1 - src/lib/stomp/schedules-dispatch.ts | 9 +- 13 files changed, 346 insertions(+), 47 deletions(-) create mode 100644 src/lib/plan/scheduleStompRouteScope.test.ts create mode 100644 src/lib/rooms.hydrate.test.ts 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/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 15da9a28..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,7 +166,8 @@ 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, refetchOnWindowFocus: false, @@ -210,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( @@ -242,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); }, }); @@ -265,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); }, }); } @@ -385,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, @@ -423,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, @@ -557,7 +560,7 @@ export function useUpdateRoom() { if (datesChanged) { await invalidateExpenses(queryClient, roomId); - await hydrateRoomSchedulesFromServer(queryClient, roomId); + await refreshRoomSchedules(queryClient, roomId); } }, }); 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/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/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/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; } From b28a4151d9ce91573834a7a9b830513b489eee89 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:31:53 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix(stomp):=20=EB=B0=A9=20=ED=86=A0?= =?UTF-8?q?=ED=94=BD=EC=9D=84=20=EA=B5=AC=EB=8F=85=ED=95=A0=20=EB=95=8C?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EB=86=93=EC=B9=9C=20=EB=B0=A9=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=8B=A4=EC=8B=9C=20=EB=B0=9B=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 구독·재연결·/home 복귀·방 전환 때 일정·북마크·안읽음 배지를 무효화 - 개인 큐를 구독할 때 놓친 입장 요청 목록을 무효화 - 화면에 있는 조회만 바로 다시 받고, 준비물·가계부·채팅 목록은 대상에서 제외 Co-Authored-By: Claude Opus 5.5 --- src/lib/query-keys.ts | 5 ++ src/lib/stomp/room-resync-dispatch.test.ts | 77 +++++++++++++++++++++ src/lib/stomp/room-resync-dispatch.ts | 40 +++++++++++ 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 + 6 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/lib/stomp/room-resync-dispatch.test.ts create mode 100644 src/lib/stomp/room-resync-dispatch.ts 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/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/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(); From 1d87aba7ea0f87522f05a56b9b27de9c402a52f5 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:32:58 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix(ui):=20=EC=9E=AC=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EA=B0=80=20=EC=8B=A4=ED=8C=A8=ED=95=B4=EB=8F=84=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8D=98=20=EC=9D=BC=EC=A0=95=C2=B7=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EB=AA=A9=EB=A1=9D=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모바일 일정·경로 카드·북마크 폴더·폴더 상세·북마크에서 추가 모달은 처음 불러오기에 실패했을 때만 오류 화면 - 경로 카드는 재조회 실패 뒤에도 받아 둔 일정으로 장소 목록을 계속 읽음 Co-Authored-By: Claude Opus 5.5 --- .../bookmark/_components/BookmarkFolderDetailView.tsx | 3 ++- src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx | 6 ++++-- src/app/(main)/map/_components/useMobileRouteView.tsx | 5 +++-- .../plan/_components/itinerary/AddFromBookmarkModal.tsx | 4 ++-- src/app/(main)/plan/_components/itinerary/PlanPageView.tsx | 4 +++- 5 files changed, 14 insertions(+), 8 deletions(-) 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} /> From 4491236855b5f337cf9c647390954fc76a552391 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:34:22 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix(stomp):=20=EB=A9=A4=EB=B2=84=C2=B7?= =?UTF-8?q?=EC=A0=91=EC=86=8D=20=EC=83=81=ED=83=9C=20=ED=8C=A8=EC=B9=98?= =?UTF-8?q?=EA=B0=80=20=EB=8B=A4=EC=8B=9C=20=EB=B0=9B=EC=9D=84=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=20=EB=AA=A9=EB=A1=9D=EC=9D=98=20=EC=9E=AC=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=A7=80=EC=9A=B0=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 무효화된 멤버 목록에는 이벤트를 패치하지 않아 다음 마운트 때 재조회 유지 - 멤버 목록을 받은 적이 없으면 한 명짜리 목록을 만들지 않음 Co-Authored-By: Claude Opus 5.5 --- src/lib/stomp/members-dispatch.test.ts | 76 ++++++++++++++++++++++++++ src/lib/stomp/members-dispatch.tsx | 58 +++++++++++--------- src/lib/stomp/presence-dispatch.tsx | 5 ++ 3 files changed, 114 insertions(+), 25 deletions(-) create mode 100644 src/lib/stomp/members-dispatch.test.ts 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), ); From a6de6f22aa720401c434804ab2627a535d31eeb1 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:36:29 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix(plan):=20=EB=93=9C=EB=9E=98=EA=B7=B8?= =?UTF-8?q?=20=EC=A4=91=20=EC=9D=BC=EC=A0=95=EC=9D=B4=20=EB=B0=94=EB=80=8C?= =?UTF-8?q?=EB=A9=B4=20=EC=88=9C=EC=84=9C=EB=A5=BC=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데스크톱 같은 일차·다른 일차, 모바일, 일차 이동 모두 드래그 시작 때 순서와 놓을 때 순서를 비교 - 같은 일차 이동은 드롭 시점 목록 대신 드래그한 항목 id로 요청 Co-Authored-By: Claude Opus 5.5 --- .../_components/mobile/mobilePlanDrag.tsx | 26 ++++++++++- ...usePlanItineraryReorder.analytics.test.tsx | 44 +++++++++++++++++++ src/hooks/usePlanItineraryReorder.ts | 24 +++++++++- src/hooks/usePlanScheduleDayReorder.ts | 15 +++++++ src/lib/plan/planItemReorder.ts | 16 +++++++ 5 files changed, 122 insertions(+), 3 deletions(-) diff --git a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx index 5b34c5d6..5c82b5b7 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx @@ -19,7 +19,11 @@ import { } from "@/hooks/useRooms"; import { bucketItemCount } from "@/lib/analytics/context"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; -import { clampCrossDayTargetOrderIndex } from "@/lib/plan/planItemReorder"; +import { + clampCrossDayTargetOrderIndex, + PLAN_ORDER_CHANGED_MESSAGE, + sameIdOrder, +} from "@/lib/plan/planItemReorder"; import { newOrderIndexAfterMove } from "@/lib/plan/scheduleItemPlaces"; import type { PlanPlace } from "@/lib/plan/types"; @@ -46,6 +50,8 @@ type ActiveDrag = { dy: number; targetScheduleId: number; targetIndex: number; + /** 드래그를 시작할 때 일차별 `itemId` 순서 */ + orders: Map; }; /** 서버 반영 전 먼저 보여줄 이동 */ @@ -236,6 +242,18 @@ export function useMobilePlanDragController({ if (typeof itemId !== "number") return; const sourcePlaces = placesBySchedule.current.get(sourceScheduleId) ?? []; + const orderChanged = [sourceScheduleId, targetScheduleId].some( + (sid) => + !sameIdOrder( + drag.orders.get(sid) ?? [], + (placesBySchedule.current.get(sid) ?? []).map((p) => p.itemId), + ), + ); + if (orderChanged) { + toast.error(PLAN_ORDER_CHANGED_MESSAGE); + return; + } + if (targetScheduleId === sourceScheduleId) { if (targetIndex === sourceIndex) return; const order = sourcePlaces.map((p) => p.id); @@ -314,6 +332,12 @@ export function useMobilePlanDragController({ dy: 0, targetScheduleId: scheduleId, targetIndex: index, + orders: new Map( + [...placesBySchedule.current].map(([sid, places]) => [ + sid, + places.map((p) => p.itemId), + ]), + ), }); }, [dragDisabled, editing, toContentY, updateActive], diff --git a/src/hooks/usePlanItineraryReorder.analytics.test.tsx b/src/hooks/usePlanItineraryReorder.analytics.test.tsx index 03f1e9f0..b96051cf 100644 --- a/src/hooks/usePlanItineraryReorder.analytics.test.tsx +++ b/src/hooks/usePlanItineraryReorder.analytics.test.tsx @@ -12,6 +12,8 @@ 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 } })); 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 +61,45 @@ it("attributes a completed drag reorder to the request's room after the hook swi client.clear(); } }); + +it("does not save a drop when the day's list changed during the drag", async () => { + api.reorder.mockClear(); + toastError.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).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledOnce(); + } finally { + document.dispatchEvent(new Event("dragend")); + await hook.unmount(); + client.clear(); + } +}); diff --git a/src/hooks/usePlanItineraryReorder.ts b/src/hooks/usePlanItineraryReorder.ts index 0e682e0f..53041a8b 100644 --- a/src/hooks/usePlanItineraryReorder.ts +++ b/src/hooks/usePlanItineraryReorder.ts @@ -24,9 +24,12 @@ import { executePlanItemCrossDayMove, getPlanItemRowReorderStyle, isPlanItemDrag, + PLAN_ORDER_CHANGED_MESSAGE, type PlanItemRowPreviewMode, PlanItemRowLayoutSnapshot, readPlanItemDragIndex, + readPlanItemDragItemId, + sameIdOrder, } from "@/lib/plan/planItemReorder"; import { newOrderIndexAfterMove } from "@/lib/plan/scheduleItemPlaces"; import type { PlanPlace } from "@/lib/plan/types"; @@ -77,6 +80,8 @@ export function usePlanItineraryReorder({ const pointerYRef = useRef(0); const dragOverRafRef = useRef(0); const externalCaptureRafRef = useRef(0); + /** 드래그를 시작(다른 일차에서 들어온 경우 이 목록에 들어온) 때의 이 일차 순서 */ + const dragOrderRef = useRef<(number | undefined)[] | null>(null); const mutationLocked = interactionLocked || isReorderSettling || isMovePending; @@ -89,6 +94,7 @@ export function usePlanItineraryReorder({ setPreviewIndex(null); setIncomingExternalDrag(false); layoutSnapshotRef.current.clear(); + dragOrderRef.current = null; if (externalCaptureRafRef.current) { cancelAnimationFrame(externalCaptureRafRef.current); externalCaptureRafRef.current = 0; @@ -130,6 +136,7 @@ export function usePlanItineraryReorder({ if (rowsReady || attempt >= 8) { layoutSnapshotRef.current.capture(rowRefs.current); + dragOrderRef.current = places.map((p) => p.itemId); setIncomingExternalDrag(true); setPreviewIndex( places.length > 0 @@ -150,7 +157,7 @@ export function usePlanItineraryReorder({ } tryCapture(); }, - [places.length], + [places], ); useEffect(() => { @@ -175,6 +182,7 @@ export function usePlanItineraryReorder({ itemId, }); layoutSnapshotRef.current.capture(rowRefs.current); + dragOrderRef.current = places.map((p) => p.itemId); const rowHeight = layoutSnapshotRef.current.heights()[index] ?? target.getBoundingClientRect().height; @@ -236,6 +244,7 @@ export function usePlanItineraryReorder({ setIncomingExternalDrag(false); setPreviewIndex(null); layoutSnapshotRef.current.clear(); + dragOrderRef.current = null; if (externalCaptureRafRef.current) { cancelAnimationFrame(externalCaptureRafRef.current); externalCaptureRafRef.current = 0; @@ -260,6 +269,16 @@ export function usePlanItineraryReorder({ previewIndex ?? (Number.isFinite(fromIndex) ? fromIndex : null); + const orderAtDragStart = dragOrderRef.current; + if ( + orderAtDragStart && + !sameIdOrder(orderAtDragStart, places.map((p) => p.itemId)) + ) { + resetDragPreview(); + toast.error(PLAN_ORDER_CHANGED_MESSAGE); + return; + } + const crossDayHandled = await executePlanItemCrossDayMove({ dataTransfer, roomId, @@ -290,7 +309,8 @@ export function usePlanItineraryReorder({ return; } - const itemId = places[fromIndex]?.itemId; + const itemId = + readPlanItemDragItemId(dataTransfer) ?? places[fromIndex]?.itemId; if (typeof itemId !== "number") return; try { diff --git a/src/hooks/usePlanScheduleDayReorder.ts b/src/hooks/usePlanScheduleDayReorder.ts index f8cb348d..e0ce4c1a 100644 --- a/src/hooks/usePlanScheduleDayReorder.ts +++ b/src/hooks/usePlanScheduleDayReorder.ts @@ -15,7 +15,9 @@ import type { RoomSchedule } from "@/lib/api/rooms"; import { computeRowTranslateY, getPlanItemRowReorderStyle, + PLAN_ORDER_CHANGED_MESSAGE, PlanItemRowLayoutSnapshot, + sameIdOrder, } from "@/lib/plan/planItemReorder"; import { beginPlanScheduleDayDrag, @@ -43,6 +45,8 @@ export function usePlanScheduleDayReorder({ const layoutSnapshotRef = useRef(new PlanItemRowLayoutSnapshot()); const pointerYRef = useRef(0); const dragOverRafRef = useRef(0); + /** 드래그를 시작할 때의 일차 순서 */ + const dayOrderRef = useRef(null); const dragLocked = interactionLocked || isMovePending || schedules.length < 2; @@ -54,6 +58,7 @@ export function usePlanScheduleDayReorder({ setDragFromIndex(null); setPreviewIndex(null); layoutSnapshotRef.current.clear(); + dayOrderRef.current = null; }, []); const setSectionRef = useCallback( @@ -96,6 +101,7 @@ export function usePlanScheduleDayReorder({ beginPlanScheduleDayDrag(e.nativeEvent, sectionEl, index); } layoutSnapshotRef.current.capture(sectionRefs.current); + dayOrderRef.current = schedules.map((s) => s.scheduleId); setDragFromIndex(index); setPreviewIndex(index); }, @@ -141,9 +147,18 @@ export function usePlanScheduleDayReorder({ const toIndex = previewIndex ?? (Number.isFinite(fromIndex) ? fromIndex : null); + const dayOrderAtDragStart = dayOrderRef.current; resetDragPreview(); + if ( + dayOrderAtDragStart && + !sameIdOrder(dayOrderAtDragStart, schedules.map((s) => s.scheduleId)) + ) { + toast.error(PLAN_ORDER_CHANGED_MESSAGE); + return; + } + if ( !Number.isFinite(fromIndex) || fromIndex < 0 || diff --git a/src/lib/plan/planItemReorder.ts b/src/lib/plan/planItemReorder.ts index 761bdeec..bff4abb6 100644 --- a/src/lib/plan/planItemReorder.ts +++ b/src/lib/plan/planItemReorder.ts @@ -10,6 +10,22 @@ 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"; +/** + * 드래그하는 동안 목록이 바뀌면(다른 사람의 변경·재조회) 사용자가 보던 자리와 서버 자리가 달라진다. + * 재정렬 API는 절대 위치만 받으므로 그대로 보내면 의도와 다른 순서가 저장된다 — 저장하지 않고 알린다. + */ +export const PLAN_ORDER_CHANGED_MESSAGE = "일정이 바뀌어서 옮기지 않았어요. 다시 옮겨 주세요."; + +/** 드래그를 시작할 때와 놓을 때의 id 순서가 같은지 */ +export function sameIdOrder( + before: readonly unknown[], + after: readonly unknown[], +): boolean { + return ( + before.length === after.length && before.every((id, i) => id === after[i]) + ); +} + export const PLAN_ITEM_REORDER_ROW_TRANSITION = "transform 200ms cubic-bezier(0.2, 0, 0, 1)"; From 57a293169dd61a679399cbd6a250dbf5e7d5f5cc Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:41:13 +0900 Subject: [PATCH 11/16] =?UTF-8?q?fix(plan):=20=EC=97=B0=EA=B2=B0=EC=9D=B4?= =?UTF-8?q?=20=EB=81=8A=EA=B8=B4=20=EB=8F=99=EC=95=88=EA=B3=BC=20=EB=86=93?= =?UTF-8?q?=EC=B9=9C=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=20=EB=8F=99=EC=95=88=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=9E=A0=EA=B8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데스크톱·모바일·일차 이동 손잡이는 기존 비활성 표시를 쓰고 목록은 그대로 보임 - 드래그 중에 끊기면 놓아도 저장하지 않고 알림 Co-Authored-By: Claude Opus 5.5 --- .../_components/mobile/mobilePlanDrag.test.ts | 1 + .../_components/mobile/mobilePlanDrag.tsx | 14 +++++- ...usePlanItineraryReorder.analytics.test.tsx | 47 +++++++++++++++++++ src/hooks/usePlanItineraryReorder.ts | 22 +++++++-- src/hooks/usePlanScheduleDayReorder.ts | 19 ++++++-- src/hooks/useScheduleResyncPending.ts | 16 +++++++ src/lib/plan/planItemReorder.ts | 4 ++ 7 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useScheduleResyncPending.ts 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 5c82b5b7..3417443a 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx @@ -17,11 +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, PLAN_ORDER_CHANGED_MESSAGE, + PLAN_ORDER_RESYNCING_MESSAGE, sameIdOrder, } from "@/lib/plan/planItemReorder"; import { newOrderIndexAfterMove } from "@/lib/plan/scheduleItemPlaces"; @@ -169,7 +171,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; @@ -242,6 +246,12 @@ export function useMobilePlanDragController({ if (typeof itemId !== "number") return; const sourcePlaces = placesBySchedule.current.get(sourceScheduleId) ?? []; + // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 + if (scheduleResyncPending) { + toast.error(PLAN_ORDER_RESYNCING_MESSAGE); + return; + } + const orderChanged = [sourceScheduleId, targetScheduleId].some( (sid) => !sameIdOrder( @@ -298,7 +308,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/hooks/usePlanItineraryReorder.analytics.test.tsx b/src/hooks/usePlanItineraryReorder.analytics.test.tsx index b96051cf..aeab8d32 100644 --- a/src/hooks/usePlanItineraryReorder.analytics.test.tsx +++ b/src/hooks/usePlanItineraryReorder.analytics.test.tsx @@ -14,6 +14,8 @@ vi.mock("@/lib/api/rooms", async (original) => ({ ...await original(), r 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 () => { @@ -103,3 +105,48 @@ it("does not save a drop when the day's list changed during the drag", async () 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 53041a8b..1ec7f45c 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, @@ -25,6 +26,7 @@ import { getPlanItemRowReorderStyle, isPlanItemDrag, PLAN_ORDER_CHANGED_MESSAGE, + PLAN_ORDER_RESYNCING_MESSAGE, type PlanItemRowPreviewMode, PlanItemRowLayoutSnapshot, readPlanItemDragIndex, @@ -85,6 +87,7 @@ export function usePlanItineraryReorder({ const mutationLocked = interactionLocked || isReorderSettling || isMovePending; + const scheduleResyncPending = useScheduleResyncPending(roomId); const isDraggingActive = dragFromIndex !== null || incomingExternalDrag; useDragAutoScroll({ active: isDraggingActive }); @@ -174,7 +177,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, { @@ -190,7 +195,7 @@ export function usePlanItineraryReorder({ setDragFromIndex(index); setPreviewIndex(index); }, - [beginItemDrag, mutationLocked, places, scheduleId], + [beginItemDrag, mutationLocked, places, scheduleId, scheduleResyncPending], ); const handleDragEnd = useCallback(() => { @@ -269,6 +274,13 @@ export function usePlanItineraryReorder({ previewIndex ?? (Number.isFinite(fromIndex) ? fromIndex : null); + // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 + if (scheduleResyncPending) { + resetDragPreview(); + toast.error(PLAN_ORDER_RESYNCING_MESSAGE); + return; + } + const orderAtDragStart = dragOrderRef.current; if ( orderAtDragStart && @@ -343,6 +355,7 @@ export function usePlanItineraryReorder({ resetDragPreview, roomId, scheduleId, + scheduleResyncPending, ], ); @@ -397,7 +410,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, }, @@ -412,6 +427,7 @@ export function usePlanItineraryReorder({ mutationLocked, places, previewIndex, + scheduleResyncPending, setRowRef, ], ); diff --git a/src/hooks/usePlanScheduleDayReorder.ts b/src/hooks/usePlanScheduleDayReorder.ts index e0ce4c1a..71ca96ff 100644 --- a/src/hooks/usePlanScheduleDayReorder.ts +++ b/src/hooks/usePlanScheduleDayReorder.ts @@ -10,12 +10,14 @@ 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_CHANGED_MESSAGE, + PLAN_ORDER_RESYNCING_MESSAGE, PlanItemRowLayoutSnapshot, sameIdOrder, } from "@/lib/plan/planItemReorder"; @@ -48,6 +50,7 @@ export function usePlanScheduleDayReorder({ /** 드래그를 시작할 때의 일차 순서 */ const dayOrderRef = useRef(null); + const scheduleResyncPending = useScheduleResyncPending(roomId); const dragLocked = interactionLocked || isMovePending || schedules.length < 2; const isDraggingActive = dragFromIndex !== null; @@ -95,7 +98,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); @@ -105,7 +108,7 @@ export function usePlanScheduleDayReorder({ setDragFromIndex(index); setPreviewIndex(index); }, - [dragLocked, schedules], + [dragLocked, scheduleResyncPending, schedules], ); const handleDragEnd = useCallback(() => { @@ -151,6 +154,12 @@ export function usePlanScheduleDayReorder({ resetDragPreview(); + // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 + if (scheduleResyncPending) { + toast.error(PLAN_ORDER_RESYNCING_MESSAGE); + return; + } + if ( dayOrderAtDragStart && !sameIdOrder(dayOrderAtDragStart, schedules.map((s) => s.scheduleId)) @@ -190,7 +199,7 @@ export function usePlanScheduleDayReorder({ ); } }, - [moveMutate, previewIndex, resetDragPreview, roomId, schedules], + [moveMutate, previewIndex, resetDragPreview, roomId, scheduleResyncPending, schedules], ); const getSectionProps = useCallback( @@ -224,7 +233,8 @@ export function usePlanScheduleDayReorder({ motionEnabled: isDraggingActive, }), dragHandleProps: { - dragDisabled: dragLocked || schedules[index] == null, + dragDisabled: + dragLocked || scheduleResyncPending || schedules[index] == null, isDragging, onDragStart: handleDragStart(index), onDragEnd: handleDragEnd, @@ -238,6 +248,7 @@ export function usePlanScheduleDayReorder({ handleDragStart, isDraggingActive, previewIndex, + scheduleResyncPending, schedules, setSectionRef, ], 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/plan/planItemReorder.ts b/src/lib/plan/planItemReorder.ts index bff4abb6..c7ab5748 100644 --- a/src/lib/plan/planItemReorder.ts +++ b/src/lib/plan/planItemReorder.ts @@ -16,6 +16,10 @@ export const PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR = "data-plan-itinerary-drop-zone */ export const PLAN_ORDER_CHANGED_MESSAGE = "일정이 바뀌어서 옮기지 않았어요. 다시 옮겨 주세요."; +/** 연결이 끊긴 동안이나 놓친 변경을 다시 받는 동안 놓은 경우 */ +export const PLAN_ORDER_RESYNCING_MESSAGE = + "일정을 다시 맞추는 중이라 옮기지 않았어요. 잠시 후 다시 옮겨 주세요."; + /** 드래그를 시작할 때와 놓을 때의 id 순서가 같은지 */ export function sameIdOrder( before: readonly unknown[], From 7b9c66a54a1b619282e6622724222aeeb586c509 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 20:12:53 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix(chat):=20=EC=97=B0=EA=B2=B0=EC=9D=B4?= =?UTF-8?q?=20=EB=B0=94=EB=80=8C=EC=96=B4=EB=8F=84=20=EC=9D=BD=EC=9D=8C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=95=A8=EC=88=98=EB=A5=BC=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - STOMP client·연결 상태를 보낼 때 ref로 읽어 markMessagesRead가 연결마다 새로 만들어지지 않게 함 - 이 함수에 기대는 채팅 최초 기록 조회가 재연결 때마다 다시 시작되던 원인을 없앰 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useChatMessageRead.test.tsx | 23 +++++++++++++++++++++++ src/hooks/useChatMessageRead.ts | 13 ++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) 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(() => { From e5f165df31c7d1099eaefb0dd558ce4d99574ed5 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 20:12:53 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix(chat):=20=EC=8B=A4=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20=EC=B1=84=ED=8C=85=EC=9D=84=20sequence=20=EC=BB=A4=EC=84=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=99=EC=9D=B4=EA=B3=A0=20=EB=86=93=EC=B9=9C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20=EB=81=9D=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=94=B0=EB=9D=BC=EC=9E=A1=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기록 조회·최신으로 이동·아래 페이지·따라잡기 중 받은 메시지를 모았다가 결과 반영 뒤 같은 규칙으로 처리(로딩 중 받은 일반 채팅 유실 수정) - 커서+1만 목록 끝에 붙이고, 번호가 비면 afterSequence로 200개씩 끝까지 받음 - 재연결·다른 방에서 돌아올 때 놓친 메시지를 따라잡고, 재연결로 새로 붙은 메시지 앞에 구분선을 두고 그 자리로 스크롤 - 중간 창에서는 새 메시지를 붙이지 않고 같은 id만 갱신, AI 요청은 대기 상태로 되돌리지 않음 - 빈 방은 afterSequence=0 대신 최신 페이지를 받고, 400·403·404는 다시 보내지 않음(네트워크·5xx는 1·2·4초 간격 재시도) - 최초 기록 조회가 재연결로 멈추지 않게 하고, 끝내 실패했으면 재연결 때 다시 받음 - 목록을 sequence 순으로 정렬하고, 최신으로 이동은 목록을 비우지 않고 한 번에 교체 - 메시지·읽음 위치 조회 API가 HttpError를 던지게 함 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useChatMessages.recovery.test.tsx | 317 ++++++++++ src/hooks/useChatMessages.ts | 641 ++++++++++++-------- src/lib/api/rooms/messages.ts | 2 + src/lib/chat.test.ts | 135 +++++ src/lib/chat.ts | 131 +++- src/lib/chat/messageCatchUp.test.ts | 120 ++++ src/lib/chat/messageCatchUp.ts | 87 +++ 7 files changed, 1153 insertions(+), 280 deletions(-) create mode 100644 src/hooks/useChatMessages.recovery.test.tsx 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 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..0e8f4ab6 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,33 @@ 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, + ); + if (!out.lastReadMessageId) { + return { ...out, slice: out.serverSlice, placementBeforeSlice: out.serverSlice }; } + + const bridged = await mergeSliceWithReadBridge( + roomId, + out.lastReadMessageId, + out.serverSlice, + out.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 +168,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 +198,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 +345,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 +397,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 +627,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 +645,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 +708,6 @@ export function useChatMessages( setRawMessages((prev) => mergeServerMessageLists(normalizedHistory, prev), ); - bumpRoomLastSequence(lastSequenceByRoomRef.current, rid, normalizedHistory); void warmPlacePhotoQueriesFromChatHistory( queryClient, normalizedHistory, @@ -572,8 +724,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 +735,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 +753,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 +794,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(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 +824,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/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/chat.test.ts b/src/lib/chat.test.ts new file mode 100644 index 00000000..f6996ce8 --- /dev/null +++ b/src/lib/chat.test.ts @@ -0,0 +1,135 @@ +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("orders by sequence even when timestamps disagree", () => { + const late = { ...msg(41), createdAt: "2026-10-25T01:00:00Z" }; + expect(ids(mergeServerMessageLists([msg(42)], [late]))).toEqual(["m41", "m42"]); + }); + + 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..a9fe833a 100644 --- a/src/lib/chat.ts +++ b/src/lib/chat.ts @@ -589,16 +589,37 @@ 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 compareServerMessageOrder( + x: ServerChatMessage, + y: ServerChatMessage, +): number { + if (x.sequence != null && y.sequence != null) return x.sequence - y.sequence; + return x.createdAt.localeCompare(y.createdAt); +} + +/** 같은 id는 하나만 유지하고 서버 순서로 정렬한다 */ export function mergeServerMessageLists( ...lists: ServerChatMessage[][] ): ServerChatMessage[] { @@ -613,7 +634,94 @@ export function mergeServerMessageLists( map.set(m.id, preferNewerServerMessage(prev, m)); } } - return [...map.values()].sort((x, y) => x.createdAt.localeCompare(y.createdAt)); + return [...map.values()].sort(compareServerMessageOrder); +} + +/** 목록에 이미 있는 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(compareServerMessageOrder)) { + 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[]` */ @@ -663,19 +771,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..a8f0c5b4 --- /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(450); + 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; + } +} From aca3095a1efebd46f245c8bc5b675f1e3dababfd Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 21:40:49 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix(chat):=20=EA=B1=B0=EC=9D=98=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=97=90=20=EB=B3=B4=EB=82=B8=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EC=9D=B4=20=EB=B3=B4=EB=82=B8=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EB=B3=B4=EC=9D=B4=EA=B2=8C=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버는 저장 충돌을 재시도한 순서로 sequence를 매겨, 번호 순 정렬이면 1~4가 3·4·1·2로 보였음 - 목록은 작성 시각 순으로 되돌리고, sequence 순서는 빈 번호 판단에만 사용 - 최초 기록·최신으로 이동 결과도 작성 시각 순으로 정렬 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useChatMessages.ts | 10 ++++++---- src/lib/chat.test.ts | 11 ++++++++--- src/lib/chat.ts | 13 ++++++++----- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/hooks/useChatMessages.ts b/src/hooks/useChatMessages.ts index 0e8f4ab6..0046ccfe 100644 --- a/src/hooks/useChatMessages.ts +++ b/src/hooks/useChatMessages.ts @@ -84,15 +84,17 @@ async function loadRoomHistorySnapshot(roomId: string) { lastReadMessageId, CHAT_MESSAGE_PAGE_SIZE, ); + // 서버는 페이지를 sequence·문서 id 순으로 준다 — 화면은 작성 시각 순으로 맞춘다 + const serverSlice = mergeServerMessageLists(out.serverSlice); if (!out.lastReadMessageId) { - return { ...out, slice: out.serverSlice, placementBeforeSlice: out.serverSlice }; + return { ...out, slice: serverSlice, placementBeforeSlice: serverSlice }; } const bridged = await mergeSliceWithReadBridge( roomId, out.lastReadMessageId, - out.serverSlice, - out.serverSlice, + serverSlice, + serverSlice, ); return { ...out, @@ -800,7 +802,7 @@ export function useChatMessages( clearSessionReadBoundary(rid); setHasMore(more); setHasMoreNewer(false); - setRawMessages(normalized); + setRawMessages(mergeServerMessageLists(normalized)); publishScrollToAnchor(undefined); void warmPlacePhotoQueriesFromChatHistory(queryClient, normalized); finishTailSync(rid, true); diff --git a/src/lib/chat.test.ts b/src/lib/chat.test.ts index f6996ce8..21862df5 100644 --- a/src/lib/chat.test.ts +++ b/src/lib/chat.test.ts @@ -85,9 +85,14 @@ describe("partitionLiveMessages", () => { }); describe("mergeServerMessageLists", () => { - it("orders by sequence even when timestamps disagree", () => { - const late = { ...msg(41), createdAt: "2026-10-25T01:00:00Z" }; - expect(ids(mergeServerMessageLists([msg(42)], [late]))).toEqual(["m41", "m42"]); + 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", () => { diff --git a/src/lib/chat.ts b/src/lib/chat.ts index a9fe833a..673569e2 100644 --- a/src/lib/chat.ts +++ b/src/lib/chat.ts @@ -610,8 +610,8 @@ function preferNewerServerMessage( return before !== undefined && after !== undefined && after < before ? a : b; } -/** 방별 sequence 순서. sequence가 없는 옛 메시지는 작성 시각으로 비교한다 */ -function compareServerMessageOrder( +/** 커서 판단용 방별 sequence 순서. sequence가 없는 옛 메시지는 작성 시각으로 비교한다 */ +function compareBySequence( x: ServerChatMessage, y: ServerChatMessage, ): number { @@ -619,7 +619,10 @@ function compareServerMessageOrder( return x.createdAt.localeCompare(y.createdAt); } -/** 같은 id는 하나만 유지하고 서버 순서로 정렬한다 */ +/** + * 같은 id는 하나만 유지하고 작성 시각 순으로 정렬한다. + * 거의 동시에 저장된 메시지는 서버가 충돌을 재시도한 순서로 sequence를 받아 보낸 순서와 다를 수 있다. + */ export function mergeServerMessageLists( ...lists: ServerChatMessage[][] ): ServerChatMessage[] { @@ -634,7 +637,7 @@ export function mergeServerMessageLists( map.set(m.id, preferNewerServerMessage(prev, m)); } } - return [...map.values()].sort(compareServerMessageOrder); + return [...map.values()].sort((x, y) => x.createdAt.localeCompare(y.createdAt)); } /** 목록에 이미 있는 id만 새 내용으로 바꾼다. 새 id는 붙이지 않는다 */ @@ -691,7 +694,7 @@ export function partitionLiveMessages( const updates: ServerChatMessage[] = []; let next = cursor; let hasGap = false; - for (const msg of [...msgs].sort(compareServerMessageOrder)) { + for (const msg of [...msgs].sort(compareBySequence)) { const seq = msg.sequence; if (!atTail) { updates.push(msg); From 715a4331b4af552bc63969f61fab3c0a76eced4c Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 26 Oct 2026 13:31:29 +0900 Subject: [PATCH 15/16] =?UTF-8?q?test(chat):=20=EB=86=93=EC=B9=9C=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9D=98=20=EA=B8=B0=EB=8C=80=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/lib/chat/messageCatchUp.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/chat/messageCatchUp.test.ts b/src/lib/chat/messageCatchUp.test.ts index a8f0c5b4..4bbc971b 100644 --- a/src/lib/chat/messageCatchUp.test.ts +++ b/src/lib/chat/messageCatchUp.test.ts @@ -40,7 +40,7 @@ describe("fetchRoomMessagesAfter", () => { const result = await fetchRoomMessagesAfter("room", 40, active); - expect(result).toHaveLength(450); + 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 }], From ccade96917cb94df71d7aeda8a7cc90c7227416c Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 26 Oct 2026 13:48:28 +0900 Subject: [PATCH 16/16] =?UTF-8?q?fix(plan):=20=EB=93=9C=EB=9E=98=EA=B7=B8?= =?UTF-8?q?=20=EC=A4=91=20=EC=9D=BC=EC=A0=95=EC=9D=B4=20=EB=B0=94=EB=80=8C?= =?UTF-8?q?=EC=96=B4=EB=8F=84=20=EB=86=93=EC=9D=80=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=9C=EC=84=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 드래그 시작과 놓을 때의 순서 비교, "일정이 바뀌어서 옮기지 않았어요" 알림 제거(데스크톱·모바일·일차 이동) - 같은 일차 이동은 계속 드래그한 항목 id로 요청 - 연결이 끊긴 동안과 다시 받는 동안의 드래그 잠금은 유지 Co-Authored-By: Claude Opus 5.5 --- .../_components/mobile/mobilePlanDrag.tsx | 22 ------------------- ...usePlanItineraryReorder.analytics.test.tsx | 6 ++--- src/hooks/usePlanItineraryReorder.ts | 20 +---------------- src/hooks/usePlanScheduleDayReorder.ts | 15 ------------- src/lib/plan/planItemReorder.ts | 16 -------------- 5 files changed, 3 insertions(+), 76 deletions(-) diff --git a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx index 3417443a..5d0b3462 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx @@ -22,9 +22,7 @@ import { bucketItemCount } from "@/lib/analytics/context"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { clampCrossDayTargetOrderIndex, - PLAN_ORDER_CHANGED_MESSAGE, PLAN_ORDER_RESYNCING_MESSAGE, - sameIdOrder, } from "@/lib/plan/planItemReorder"; import { newOrderIndexAfterMove } from "@/lib/plan/scheduleItemPlaces"; import type { PlanPlace } from "@/lib/plan/types"; @@ -52,8 +50,6 @@ type ActiveDrag = { dy: number; targetScheduleId: number; targetIndex: number; - /** 드래그를 시작할 때 일차별 `itemId` 순서 */ - orders: Map; }; /** 서버 반영 전 먼저 보여줄 이동 */ @@ -252,18 +248,6 @@ export function useMobilePlanDragController({ return; } - const orderChanged = [sourceScheduleId, targetScheduleId].some( - (sid) => - !sameIdOrder( - drag.orders.get(sid) ?? [], - (placesBySchedule.current.get(sid) ?? []).map((p) => p.itemId), - ), - ); - if (orderChanged) { - toast.error(PLAN_ORDER_CHANGED_MESSAGE); - return; - } - if (targetScheduleId === sourceScheduleId) { if (targetIndex === sourceIndex) return; const order = sourcePlaces.map((p) => p.id); @@ -342,12 +326,6 @@ export function useMobilePlanDragController({ dy: 0, targetScheduleId: scheduleId, targetIndex: index, - orders: new Map( - [...placesBySchedule.current].map(([sid, places]) => [ - sid, - places.map((p) => p.itemId), - ]), - ), }); }, [dragDisabled, editing, toContentY, updateActive], diff --git a/src/hooks/usePlanItineraryReorder.analytics.test.tsx b/src/hooks/usePlanItineraryReorder.analytics.test.tsx index aeab8d32..452d1cff 100644 --- a/src/hooks/usePlanItineraryReorder.analytics.test.tsx +++ b/src/hooks/usePlanItineraryReorder.analytics.test.tsx @@ -64,9 +64,8 @@ it("attributes a completed drag reorder to the request's room after the hook swi } }); -it("does not save a drop when the day's list changed during the drag", async () => { +it("saves the dragged item at the dropped index even when the day's list changed during the drag", async () => { api.reorder.mockClear(); - toastError.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 } } }); @@ -97,8 +96,7 @@ it("does not save a drop when the day's list changed during the drag", async () 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).not.toHaveBeenCalled(); - expect(toastError).toHaveBeenCalledOnce(); + expect(api.reorder).toHaveBeenCalledWith("room-a", 1, 1, { newOrderIndex: 1 }); } finally { document.dispatchEvent(new Event("dragend")); await hook.unmount(); diff --git a/src/hooks/usePlanItineraryReorder.ts b/src/hooks/usePlanItineraryReorder.ts index 1ec7f45c..c248a8a2 100644 --- a/src/hooks/usePlanItineraryReorder.ts +++ b/src/hooks/usePlanItineraryReorder.ts @@ -25,13 +25,11 @@ import { executePlanItemCrossDayMove, getPlanItemRowReorderStyle, isPlanItemDrag, - PLAN_ORDER_CHANGED_MESSAGE, PLAN_ORDER_RESYNCING_MESSAGE, type PlanItemRowPreviewMode, PlanItemRowLayoutSnapshot, readPlanItemDragIndex, readPlanItemDragItemId, - sameIdOrder, } from "@/lib/plan/planItemReorder"; import { newOrderIndexAfterMove } from "@/lib/plan/scheduleItemPlaces"; import type { PlanPlace } from "@/lib/plan/types"; @@ -82,8 +80,6 @@ export function usePlanItineraryReorder({ const pointerYRef = useRef(0); const dragOverRafRef = useRef(0); const externalCaptureRafRef = useRef(0); - /** 드래그를 시작(다른 일차에서 들어온 경우 이 목록에 들어온) 때의 이 일차 순서 */ - const dragOrderRef = useRef<(number | undefined)[] | null>(null); const mutationLocked = interactionLocked || isReorderSettling || isMovePending; @@ -97,7 +93,6 @@ export function usePlanItineraryReorder({ setPreviewIndex(null); setIncomingExternalDrag(false); layoutSnapshotRef.current.clear(); - dragOrderRef.current = null; if (externalCaptureRafRef.current) { cancelAnimationFrame(externalCaptureRafRef.current); externalCaptureRafRef.current = 0; @@ -139,7 +134,6 @@ export function usePlanItineraryReorder({ if (rowsReady || attempt >= 8) { layoutSnapshotRef.current.capture(rowRefs.current); - dragOrderRef.current = places.map((p) => p.itemId); setIncomingExternalDrag(true); setPreviewIndex( places.length > 0 @@ -160,7 +154,7 @@ export function usePlanItineraryReorder({ } tryCapture(); }, - [places], + [places.length], ); useEffect(() => { @@ -187,7 +181,6 @@ export function usePlanItineraryReorder({ itemId, }); layoutSnapshotRef.current.capture(rowRefs.current); - dragOrderRef.current = places.map((p) => p.itemId); const rowHeight = layoutSnapshotRef.current.heights()[index] ?? target.getBoundingClientRect().height; @@ -249,7 +242,6 @@ export function usePlanItineraryReorder({ setIncomingExternalDrag(false); setPreviewIndex(null); layoutSnapshotRef.current.clear(); - dragOrderRef.current = null; if (externalCaptureRafRef.current) { cancelAnimationFrame(externalCaptureRafRef.current); externalCaptureRafRef.current = 0; @@ -281,16 +273,6 @@ export function usePlanItineraryReorder({ return; } - const orderAtDragStart = dragOrderRef.current; - if ( - orderAtDragStart && - !sameIdOrder(orderAtDragStart, places.map((p) => p.itemId)) - ) { - resetDragPreview(); - toast.error(PLAN_ORDER_CHANGED_MESSAGE); - return; - } - const crossDayHandled = await executePlanItemCrossDayMove({ dataTransfer, roomId, diff --git a/src/hooks/usePlanScheduleDayReorder.ts b/src/hooks/usePlanScheduleDayReorder.ts index 71ca96ff..710971fe 100644 --- a/src/hooks/usePlanScheduleDayReorder.ts +++ b/src/hooks/usePlanScheduleDayReorder.ts @@ -16,10 +16,8 @@ import type { RoomSchedule } from "@/lib/api/rooms"; import { computeRowTranslateY, getPlanItemRowReorderStyle, - PLAN_ORDER_CHANGED_MESSAGE, PLAN_ORDER_RESYNCING_MESSAGE, PlanItemRowLayoutSnapshot, - sameIdOrder, } from "@/lib/plan/planItemReorder"; import { beginPlanScheduleDayDrag, @@ -47,8 +45,6 @@ export function usePlanScheduleDayReorder({ const layoutSnapshotRef = useRef(new PlanItemRowLayoutSnapshot()); const pointerYRef = useRef(0); const dragOverRafRef = useRef(0); - /** 드래그를 시작할 때의 일차 순서 */ - const dayOrderRef = useRef(null); const scheduleResyncPending = useScheduleResyncPending(roomId); const dragLocked = @@ -61,7 +57,6 @@ export function usePlanScheduleDayReorder({ setDragFromIndex(null); setPreviewIndex(null); layoutSnapshotRef.current.clear(); - dayOrderRef.current = null; }, []); const setSectionRef = useCallback( @@ -104,7 +99,6 @@ export function usePlanScheduleDayReorder({ beginPlanScheduleDayDrag(e.nativeEvent, sectionEl, index); } layoutSnapshotRef.current.capture(sectionRefs.current); - dayOrderRef.current = schedules.map((s) => s.scheduleId); setDragFromIndex(index); setPreviewIndex(index); }, @@ -150,7 +144,6 @@ export function usePlanScheduleDayReorder({ const toIndex = previewIndex ?? (Number.isFinite(fromIndex) ? fromIndex : null); - const dayOrderAtDragStart = dayOrderRef.current; resetDragPreview(); @@ -160,14 +153,6 @@ export function usePlanScheduleDayReorder({ return; } - if ( - dayOrderAtDragStart && - !sameIdOrder(dayOrderAtDragStart, schedules.map((s) => s.scheduleId)) - ) { - toast.error(PLAN_ORDER_CHANGED_MESSAGE); - return; - } - if ( !Number.isFinite(fromIndex) || fromIndex < 0 || diff --git a/src/lib/plan/planItemReorder.ts b/src/lib/plan/planItemReorder.ts index c7ab5748..1b185ea4 100644 --- a/src/lib/plan/planItemReorder.ts +++ b/src/lib/plan/planItemReorder.ts @@ -10,26 +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"; -/** - * 드래그하는 동안 목록이 바뀌면(다른 사람의 변경·재조회) 사용자가 보던 자리와 서버 자리가 달라진다. - * 재정렬 API는 절대 위치만 받으므로 그대로 보내면 의도와 다른 순서가 저장된다 — 저장하지 않고 알린다. - */ -export const PLAN_ORDER_CHANGED_MESSAGE = "일정이 바뀌어서 옮기지 않았어요. 다시 옮겨 주세요."; - /** 연결이 끊긴 동안이나 놓친 변경을 다시 받는 동안 놓은 경우 */ export const PLAN_ORDER_RESYNCING_MESSAGE = "일정을 다시 맞추는 중이라 옮기지 않았어요. 잠시 후 다시 옮겨 주세요."; -/** 드래그를 시작할 때와 놓을 때의 id 순서가 같은지 */ -export function sameIdOrder( - before: readonly unknown[], - after: readonly unknown[], -): boolean { - return ( - before.length === after.length && before.every((id, i) => id === after[i]) - ); -} - export const PLAN_ITEM_REORDER_ROW_TRANSITION = "transform 200ms cubic-bezier(0.2, 0, 0, 1)";