diff --git a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx index c239b562..e3c07d3a 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx @@ -218,6 +218,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { ); const insertIndex = index + 1; const segmentActive = activeInsertIndex === insertIndex; + const nextItemId = places[index + 1]?.itemId; return ( {index < places.length - 1 && typeof place.itemId === "number" && - typeof places[index + 1]?.itemId === "number" ? + typeof nextItemId === "number" ? @@ -264,6 +265,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { roomId={roomId} scheduleId={scheduleId} segmentSourceItemId={place.itemId} + destinationItemId={nextItemId} scheduleFingerprint={scheduleFingerprint} travelMode={place.travelMode} originGooglePlaceId={place.googlePlaceId} @@ -275,7 +277,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { !isFetchingPlaces && routesBatchSettled && existingItemIds.has(place.itemId) && - existingItemIds.has(places[index + 1].itemId!) + existingItemIds.has(nextItemId) } /> diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx index 0b8d765c..4ce706ae 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx @@ -240,6 +240,7 @@ function PlaceTravelSegment({ roomId={roomId} scheduleId={scheduleId} segmentSourceItemId={place.itemId} + destinationItemId={next.itemId} scheduleFingerprint={scheduleFingerprint} travelMode={place.travelMode} originGooglePlaceId={place.googlePlaceId} diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.summary.test.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.summary.test.tsx index 90bd5f8d..a35386f2 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.summary.test.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.summary.test.tsx @@ -23,6 +23,7 @@ function renderSummary() { roomId: "room-1", scheduleId: 1, segmentSourceItemId: 100, + destinationItemId: 101, scheduleFingerprint: "100:a:|101:b:", travelMode: "DRIVING", contentOnly: true, diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx index 495a9a47..69af9107 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx @@ -15,7 +15,7 @@ let container: HTMLDivElement; let client: QueryClient; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - api.route.mockReset().mockImplementation(async (_r, _s, _i, travelMode) => ({ travelMode, distanceMeters: 100, durationSeconds: 60 })); + api.route.mockReset().mockImplementation(async (_r, _s, _i, _d, travelMode) => ({ travelMode, distanceMeters: 100, durationSeconds: 60 })); api.updateMode.mockReset().mockImplementation(async (_r, _s, itemId, body) => ({ itemId, scheduleId: 10, googlePlaceId: "a", orderIndex: 0, startTime: null, endTime: null, createdAt: "2026-10-28", ...body })); client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -23,7 +23,7 @@ beforeEach(() => { afterEach(async () => { await act(async () => root.unmount()); container.remove(); client.clear(); vi.unstubAllGlobals(); }); async function render(travelMode?: string) { await act(async () => root.render()); } @@ -35,11 +35,11 @@ it.each([undefined, "BICYCLING", "CYCLING", "BOAT"])("does not query or save uns await act(async () => container.querySelector("button[aria-expanded]")!.click()); const choice = [...container.querySelectorAll("button[role=option]")].find(b => b.textContent?.includes("자동차"))!; await act(async () => choice.click()); - await vi.waitFor(() => expect(api.route).toHaveBeenCalledWith("room", 10, 1, "DRIVING")); + await vi.waitFor(() => expect(api.route).toHaveBeenCalledWith("room", 10, 1, 2, "DRIVING")); expect(api.updateMode).toHaveBeenCalledExactlyOnceWith("room", 10, 1, { travelMode: "DRIVING" }); }); it.each(["DRIVING", "WALKING", "TRANSIT"])("queries supported %s without saving it", async mode => { await render(mode); - await vi.waitFor(() => expect(api.route).toHaveBeenCalledWith("room", 10, 1, mode)); + await vi.waitFor(() => expect(api.route).toHaveBeenCalledWith("room", 10, 1, 2, mode)); expect(api.updateMode).not.toHaveBeenCalled(); }); diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx index cb1aff7e..806df07d 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx @@ -40,6 +40,8 @@ export type PlanTravelTimeProps = { roomId: string; scheduleId: number; segmentSourceItemId: number; + /** 다음 장소의 `itemId` — 경로를 이 항목까지 계산한다 */ + destinationItemId: number; scheduleFingerprint: string; /** 서버 원본 공유 이동수단 — 미지원·미설정이면 선택을 기다립니다. */ travelMode?: string; @@ -57,6 +59,7 @@ export function PlanTravelTime({ roomId, scheduleId, segmentSourceItemId, + destinationItemId, scheduleFingerprint, travelMode, routeQueryEnabled = true, @@ -105,6 +108,7 @@ export function PlanTravelTime({ roomId, scheduleId, segmentSourceItemId, + destinationItemId, selectedMode, routeQueryEnabled, fpTrim, @@ -126,6 +130,7 @@ export function PlanTravelTime({ rid, scheduleId, segmentSourceItemId, + destinationItemId, mode, fpTrim, { diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 084ee376..5d7ed6f5 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -34,10 +34,6 @@ import { flattenPlanItinerarySegmentsFromPlaces, } from "@/lib/plan/planItineraryMapSegments"; import { planItinerarySegmentMapPathQueryKey } from "@/lib/query-keys"; -import { - planMapSegmentEpochStoreKey, - usePlanMapDirectionsEpochStore, -} from "@/stores/plan-map-directions-epoch-store"; import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; import { usePlanMapFocusStore } from "@/stores/plan-map-focus-store"; import { useSessionStore } from "@/stores/session-store"; @@ -49,7 +45,6 @@ import { PlanItineraryStopMapPin } from "./PlanItineraryStopMapPin"; * 일차만 지도에 경로(polyline)와 정류장 마커를 그립니다. * `routeDay`가 있으면(모바일 경로 보기) 토글과 관계없이 그 일차만 그립니다. * 폴리라인 travelMode는 서버에 저장된 구간별 공유 이동수단을 따릅니다. - * STOMP 에폭은 {@link usePlanMapDirectionsEpochStore} 참고. */ /** 모바일 경로 보기 — 고른 일차 하나만 그린다 */ export type PlanItineraryRouteDay = { @@ -116,13 +111,6 @@ export function PlanItineraryMapRoutes({ [singleDay, singleDayScheduleId, visibleByScheduleId], ); - const directionsEpoch = usePlanMapDirectionsEpochStore((s) => - rid.length > 0 ? (s.epochByRoomId[rid] ?? 0) : 0, - ); - const epochBySegmentKey = usePlanMapDirectionsEpochStore( - (s) => s.epochBySegmentKey, - ); - const orderedScheduleIdsForQueries = useMemo( () => [...visibleScheduleIds].sort((a, b) => a - b), [visibleScheduleIds], @@ -173,55 +161,43 @@ export function PlanItineraryMapRoutes({ ); const pathsPerSegmentQueries = useQueries({ - queries: segments.map((seg) => { - const epochKey = planMapSegmentEpochStoreKey( + queries: segments.map((seg) => ({ + queryKey: planItinerarySegmentMapPathQueryKey( rid, seg.scheduleId, seg.segmentSourceItemId, - ); - const segmentEpoch = epochBySegmentKey[epochKey] ?? 0; - - return { - queryKey: planItinerarySegmentMapPathQueryKey( - rid, - seg.scheduleId, - seg.segmentSourceItemId, - seg.originPlaceId, - seg.destPlaceId, - seg.travelModeCanon, - directionsEpoch, - segmentEpoch, - ), - /** - * 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. 그 쿼리에 맡겨야 - * in-flight 합치기와 재정렬 중 뒤늦게 온 응답 폐기를 React Query가 처리합니다. - */ - queryFn: async () => { - const route = await loadScheduleItemRouteForMap( - queryClient, - persistedScheduleItemRouteQueryOptions( - rid, - seg.scheduleId, - seg.segmentSourceItemId, - seg.travelModeCanon, - fingerprintByScheduleId.get(seg.scheduleId) ?? "", - { segmentReady: true }, - ), - ); - return { - path: await decodeOrientedPlanItinerarySegmentPath( - seg, - route?.encodedPolyline, - ), - }; - }, - enabled: rid.length > 0 && segments.length > 0, - staleTime: Infinity, - refetchOnMount: false, - refetchOnWindowFocus: false, - retry: false, - }; - }), + seg.destinationItemId, + seg.originPlaceId, + seg.destPlaceId, + seg.travelModeCanon, + ), + /** 구간 경로는 일정 리스트와 같은 쿼리로 가져와, 같은 구간을 받는 중이면 그 요청에 합류한다 */ + queryFn: async () => { + const route = await loadScheduleItemRouteForMap( + queryClient, + persistedScheduleItemRouteQueryOptions( + rid, + seg.scheduleId, + seg.segmentSourceItemId, + seg.destinationItemId, + seg.travelModeCanon, + fingerprintByScheduleId.get(seg.scheduleId) ?? "", + { segmentReady: true }, + ), + ); + return { + path: await decodeOrientedPlanItinerarySegmentPath( + seg, + route?.encodedPolyline, + ), + }; + }, + enabled: rid.length > 0 && segments.length > 0, + staleTime: Infinity, + refetchOnMount: false, + refetchOnWindowFocus: false, + retry: false, + })), }); const polylines: Array = []; @@ -229,13 +205,8 @@ export function PlanItineraryMapRoutes({ const query = pathsPerSegmentQueries[segIdx]; if (!query || query.isPending || query.isFetching) return; const pts = query.data?.path ?? []; - - const segEpochKey = planMapSegmentEpochStoreKey( - rid, - seg.scheduleId, - seg.segmentSourceItemId, - ); - const segEpoch = epochBySegmentKey[segEpochKey] ?? 0; + // 도착 항목이 바뀌면 다른 선이라 새로 그린다 + const polylineKey = `${seg.scheduleId}-${seg.segmentSourceItemId}-${seg.destinationItemId}-${seg.travelModeCanon}`; const routeColor = routeColorByScheduleId.get(seg.scheduleId) ?? fallbackRouteStroke; @@ -243,7 +214,7 @@ export function PlanItineraryMapRoutes({ if (pts.length) { polylines.push( { polylines.push( ({ })); vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ syncAfterCrossScheduleItemMove: vi.fn(), - applyScheduleItemDeletedOnClient: vi.fn(), + removeScheduleItemFromPlanPlacesCache: vi.fn(), })); import { useMoveRoomSchedule, diff --git a/src/hooks/usePrefetchScheduleRoutes.ts b/src/hooks/usePrefetchScheduleRoutes.ts index b4c52dd3..93c6bd48 100644 --- a/src/hooks/usePrefetchScheduleRoutes.ts +++ b/src/hooks/usePrefetchScheduleRoutes.ts @@ -7,7 +7,7 @@ import type { PlanPlace } from "@/lib/plan/types"; /** * 페이지 진입·새로고침 시 일차별 구간 경로를 저장된 공유 이동수단 기준 `routes/batch`로 1회 시딩합니다. - * 리오더·추가·삭제 등으로 장소 목록이 바뀌면 batch는 재호출하지 않고, 무효화된 구간만 개별 GET합니다. + * 리오더·추가·삭제 등으로 장소 목록이 바뀌면 batch는 재호출하지 않고, 새로 생긴 구간(받은 적 없는 출발·도착 항목)만 개별 GET합니다. */ export function usePrefetchScheduleRoutes( roomId: string, diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 14c8cda2..5fb5ac77 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -59,8 +59,8 @@ import { import { applyCreatedScheduleItemToPlanPlaces, applyRoomScheduleItemToPlanPlaces, - applyScheduleItemDeletedOnClient, createScheduleItemAtPlanIndex, + removeScheduleItemFromPlanPlacesCache, schedulePlanPlacesQueryOptions, syncAfterCrossScheduleItemMove, syncPlanPlacesAfterReorderSuccess, @@ -92,9 +92,7 @@ import { pathSuspendsStomp, } from "@/lib/stomp/stompPathPolicy"; import { persistedScheduleItemRouteQueryOptions } from "@/lib/plan/scheduleItemRoutePersistedQuery"; -import { invalidateScheduleItemRouteForWholeSchedule } from "@/lib/plan/scheduleStompRouteScope"; import type { ScheduleTravelModeValue } from "@/lib/plan/scheduleTravelMode"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; import { useSessionUser } from "@/hooks/useSessionUser"; import { applyHostTransferToCache } from "@/lib/rooms/host-transfer-cache"; import { readSessionUserId } from "@/lib/session-user-cache"; @@ -176,6 +174,8 @@ export function useScheduleItemRoute( roomId: string | null, scheduleId: number | null, segmentSourceItemId: number | null, + /** 다음 장소의 `itemId` */ + destinationItemId: number | null, travelMode: ScheduleTravelModeValue | null, /** 일정 장소 목록상 구간 소스 아이디가 존재·목록 안정 후에만 true */ routeQueryEnabled = true, @@ -189,6 +189,7 @@ export function useScheduleItemRoute( rid, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, scheduleFingerprint, { segmentReady: Boolean(routeQueryEnabled) }, @@ -206,22 +207,12 @@ export function useDeleteRoomSchedule() { roomId: string; scheduleId: number; }) => deleteRoomSchedule(roomId, scheduleId), - onSuccess: async (_data, { roomId, scheduleId }) => { + onSuccess: async (_data, { roomId }) => { await invalidateExpenses(queryClient, roomId); const id = roomId.trim(); if (!id.length) return; - const sorted = await refreshRoomSchedules(queryClient, id); - const scheduleStillExists = sorted.some((s) => s.scheduleId === scheduleId); - if (scheduleStillExists) { - await invalidateScheduleItemRouteForWholeSchedule( - queryClient, - id, - scheduleId, - ); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(id); - } - + await refreshRoomSchedules(queryClient, id); await syncRoomDetailFromServer(queryClient, id); }, }); @@ -300,7 +291,7 @@ export function useMoveScheduleItemToSchedule() { }, onSuccess: async ( _moved, - { roomId, sourceScheduleId, targetScheduleId, itemId }, + { roomId, sourceScheduleId, targetScheduleId }, ) => { await invalidateExpenses(queryClient, roomId); await syncAfterCrossScheduleItemMove( @@ -308,7 +299,6 @@ export function useMoveScheduleItemToSchedule() { roomId, sourceScheduleId, targetScheduleId, - itemId, ); }, }); @@ -450,7 +440,7 @@ export function useDeleteScheduleItem() { }) => deleteScheduleItem(vars.roomId, vars.scheduleId, vars.itemId), onSuccess: async (_, { roomId, scheduleId, itemId }) => { await invalidateExpenses(queryClient, roomId); - await applyScheduleItemDeletedOnClient( + removeScheduleItemFromPlanPlacesCache( queryClient, roomId, scheduleId, @@ -478,13 +468,12 @@ export function useReorderScheduleItem() { vars.itemId, vars.body, ), - onSuccess: (items, { roomId, scheduleId, itemId }) => + onSuccess: (items, { roomId, scheduleId }) => syncPlanPlacesAfterReorderSuccess( queryClient, roomId, scheduleId, items, - itemId, ), onSettled: (_items, _error, { roomId, scheduleId }) => invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), diff --git a/src/lib/api/client.test.ts b/src/lib/api/client.test.ts new file mode 100644 index 00000000..52f2f852 --- /dev/null +++ b/src/lib/api/client.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authenticatedFetch: vi.fn(), + getTabId: vi.fn<() => string | null>(), +})); + +vi.mock("@/lib/api/authenticated-fetch", () => ({ + authenticatedFetch: mocks.authenticatedFetch, +})); +vi.mock("@/lib/auth-session-client", () => ({ + expireClientSessionAndRedirect: vi.fn(), +})); +vi.mock("@/lib/auth", () => ({ fetchSessionUserRaw: vi.fn() })); +vi.mock("@/lib/query-client", () => ({ getQueryClient: () => null })); +vi.mock("@/lib/tab-id", () => ({ getTabId: mocks.getTabId })); + +import { apiFetch } from "./client"; +import { jsonBody } from "./http"; + +const TAB_ID = "3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"; +const REQUEST_URL = "http://localhost:8080/rooms/room-1/schedules/10/items"; + +/** apiFetch가 실제로 보낸 요청의 탭 ID 헤더 */ +function sentTabId(): string | null { + const init: RequestInit | undefined = mocks.authenticatedFetch.mock.calls[0]?.[1]; + return new Headers(init?.headers).get("X-Client-Tab-Id"); +} + +describe("apiFetch tab ID header", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authenticatedFetch.mockResolvedValue({ + response: new Response(null, { status: 204 }), + didRefresh: false, + }); + mocks.getTabId.mockReturnValue(TAB_ID); + }); + + it.each([ + ["JSON POST", { method: "POST", ...jsonBody({ placeId: "p" }) }], + ["PATCH", { method: "PATCH", ...jsonBody({ memo: "m" }) }], + ["PUT", { method: "PUT", ...jsonBody({ content: "c" }) }], + ["DELETE", { method: "DELETE" }], + ] satisfies [string, RequestInit][])("adds the header to a %s that is already preflighted", async (_, init) => { + await apiFetch(REQUEST_URL, init); + + expect(sentTabId()).toBe(TAB_ID); + }); + + it.each([ + ["GET without init", undefined], + ["GET", { method: "GET" }], + ["POST without body", { method: "POST" }], + ] satisfies [string, RequestInit | undefined][])("leaves a %s without the header", async (_, init) => { + await apiFetch(REQUEST_URL, init); + + expect(sentTabId()).toBeNull(); + }); + + it("does not add the header when the tab has no ID", async () => { + mocks.getTabId.mockReturnValue(null); + + await apiFetch(REQUEST_URL, { method: "DELETE" }); + + expect(sentTabId()).toBeNull(); + }); + + it.each([ + ["object", { "Content-Type": "application/json", "X-Extra": "1" }], + ["Headers", new Headers({ "Content-Type": "application/json", "X-Extra": "1" })], + ["array", [["Content-Type", "application/json"], ["X-Extra", "1"]]], + ] satisfies [string, HeadersInit][])("keeps existing %s headers", async (_, headers) => { + await apiFetch(REQUEST_URL, { method: "POST", headers, body: "{}" }); + + const init: RequestInit | undefined = mocks.authenticatedFetch.mock.calls[0]?.[1]; + const sent = new Headers(init?.headers); + expect(sent.get("Content-Type")).toBe("application/json"); + expect(sent.get("X-Extra")).toBe("1"); + expect(sent.get("X-Client-Tab-Id")).toBe(TAB_ID); + }); +}); diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index a71ca10c..1578a9af 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -6,6 +6,32 @@ import { readSessionUserCache, setSessionUserCache, } from "@/lib/session-user-cache"; +import { getTabId } from "@/lib/tab-id"; + +const CLIENT_TAB_ID_HEADER = "X-Client-Tab-Id"; + +/** + * 일정·북마크 STOMP 이벤트의 `originTabId`로 돌아와, 이 탭이 보낸 변경의 반향을 가려낸다. + * 이미 CORS preflight를 하는 요청(PATCH·PUT·DELETE, JSON POST)에만 싣는다 — GET·body 없는 POST에 + * 커스텀 헤더를 붙이면 preflight가 새로 생기고, 백엔드 CORS에 maxAge가 없어 Chrome은 그 결과를 약 5초만 기억한다. + */ +function withClientTabIdHeader(init: RequestInit | undefined): RequestInit | undefined { + const tabId = getTabId(); + if (!tabId || !init) return init; + + const method = (init.method ?? "GET").toUpperCase(); + const headers = new Headers(init.headers); + const isPreflighted = + method === "PATCH" || + method === "PUT" || + method === "DELETE" || + (method === "POST" && + (headers.get("Content-Type") ?? "").toLowerCase().startsWith("application/json")); + if (!isPreflighted) return init; + + headers.set(CLIENT_TAB_ID_HEADER, tabId); + return { ...init, headers }; +} /** * Drop-in replacement for fetch that automatically retries once after @@ -15,7 +41,10 @@ export async function apiFetch( input: RequestInfo | URL, init?: RequestInit, ): Promise { - const { response: res, didRefresh } = await authenticatedFetch(input, init); + const { response: res, didRefresh } = await authenticatedFetch( + input, + withClientTabIdHeader(init), + ); if (res.status === 401) { expireClientSessionAndRedirect({ diff --git a/src/lib/api/rooms/schedule-items.time.test.ts b/src/lib/api/rooms/schedule-items.time.test.ts index 0e911ac8..6cd61797 100644 --- a/src/lib/api/rooms/schedule-items.time.test.ts +++ b/src/lib/api/rooms/schedule-items.time.test.ts @@ -66,8 +66,16 @@ describe("schedule time JSON contract", () => { }), ), ); - expect(await api.getScheduleItemRoute("room", 10, 1)).toMatchObject({ + expect(await api.getScheduleItemRoute("room", 10, 1, 2)).toMatchObject({ durationSeconds: 1200, }); }); + it("asks the route to the given destination item", async () => { + apiFetch.mockResolvedValue(new Response(null, { status: 204 })); + await api.getScheduleItemRoute("room", 10, 1, 2, "WALKING"); + const url = new URL(String(apiFetch.mock.calls.at(-1)?.[0])); + expect(url.pathname).toMatch(/\/rooms\/room\/schedules\/10\/items\/1\/route$/); + expect(url.searchParams.get("destinationItemId")).toBe("2"); + expect(url.searchParams.get("travelMode")).toBe("WALKING"); + }); }); diff --git a/src/lib/api/rooms/schedule-items.ts b/src/lib/api/rooms/schedule-items.ts index fb4d4af9..6de10c06 100644 --- a/src/lib/api/rooms/schedule-items.ts +++ b/src/lib/api/rooms/schedule-items.ts @@ -87,6 +87,8 @@ export type ScheduleItemRouteBatchItem = | ({ status: "OK"; itemId: number; + /** 서버가 계산한 구간의 도착 항목(요청을 받은 순간의 다음 항목) */ + toItemId?: number; travelMode: string; distanceMeters: number; durationSeconds: number; @@ -96,6 +98,8 @@ export type ScheduleItemRouteBatchItem = | { status: "ERROR"; itemId: number; + /** 경로 없음(`NO_ROUTE`)이면 있고, 마지막 항목·항목 없음이면 서버가 생략 */ + toItemId?: number; travelMode: string; errorCode?: string; }; @@ -118,11 +122,13 @@ function readScheduleItemRouteBatchItems( export async function getScheduleItemRoute( roomId: string, scheduleId: number, - /** 구간 시작(현재) 일정 항목 ID — 명세상 ‘현재 항목에서 다음 항목으로’ */ + /** 구간 출발 일정 항목 ID */ itemId: number, + /** 구간 도착 일정 항목 ID — 보내면 서버가 그 순간의 순서와 관계없이 이 항목까지 계산한다 */ + destinationItemId: number, travelMode?: string | null, ): Promise { - const params = new URLSearchParams(); + const params = new URLSearchParams({ destinationItemId: String(destinationItemId) }); const tm = typeof travelMode === "string" && travelMode.trim().length > 0 ? travelMode.trim() diff --git a/src/lib/plan/planItineraryMapSegments.ts b/src/lib/plan/planItineraryMapSegments.ts index 0fdb9cda..22c91918 100644 --- a/src/lib/plan/planItineraryMapSegments.ts +++ b/src/lib/plan/planItineraryMapSegments.ts @@ -10,6 +10,8 @@ import { export type PlanItinerarySegmentDescriptor = { scheduleId: number; segmentSourceItemId: number; + /** 다음 장소의 `itemId` — 경로를 이 항목까지 계산한다 */ + destinationItemId: number; originPlaceId: string; destPlaceId: string; travelModeCanon: ScheduleTravelModeValue; @@ -35,7 +37,7 @@ export function flattenPlanItinerarySegmentsFromPlaces( typeof a.googlePlaceId === "string" ? a.googlePlaceId.trim() : ""; const d = typeof b.googlePlaceId === "string" ? b.googlePlaceId.trim() : ""; - if (typeof a.itemId !== "number" || !o.length || !d.length) { + if (typeof a.itemId !== "number" || typeof b.itemId !== "number" || !o.length || !d.length) { continue; } const travelModeCanon = canonicalScheduleTravelMode(a.travelMode); @@ -43,6 +45,7 @@ export function flattenPlanItinerarySegmentsFromPlaces( segments.push({ scheduleId, segmentSourceItemId: a.itemId, + destinationItemId: b.itemId, originPlaceId: o, destPlaceId: d, travelModeCanon, diff --git a/src/lib/plan/planTravelLocalStorage.test.ts b/src/lib/plan/planTravelLocalStorage.test.ts new file mode 100644 index 00000000..1d32417c --- /dev/null +++ b/src/lib/plan/planTravelLocalStorage.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + readScheduleRouteFromSessionStorage, + writeScheduleRouteToSessionStorage, +} from "./planTravelLocalStorage"; + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + key: (index) => [...map.keys()][index] ?? null, + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => { + map.set(key, String(value)); + }, + removeItem: (key) => { + map.delete(key); + }, + clear: () => map.clear(), + }; +} + +describe("구간 경로 sessionStorage", () => { + beforeEach(() => { + const storage = memoryStorage(); + vi.stubGlobal("window", { sessionStorage: storage }); + vi.stubGlobal("sessionStorage", storage); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("출발 항목과 일정 지문이 같아도 도착 항목이 다르면 저장한 경로를 쓰지 않는다", () => { + const toC = { distanceMeters: 999, durationSeconds: 60, travelMode: "DRIVING" }; + // 순서 저장 중 새 순서([1,3,2])로 그린 칸이 옛 순서([1,2,3])의 지문으로 1→3 경로를 저장한 경우 + writeScheduleRouteToSessionStorage("room-1", 10, 1, 3, "DRIVING", "fp-1-2-3", toC); + + expect( + readScheduleRouteFromSessionStorage("room-1", 10, 1, 2, "DRIVING", "fp-1-2-3"), + ).toBeUndefined(); + expect( + readScheduleRouteFromSessionStorage("room-1", 10, 1, 3, "DRIVING", "fp-1-2-3"), + ).toEqual(toC); + }); +}); diff --git a/src/lib/plan/planTravelLocalStorage.ts b/src/lib/plan/planTravelLocalStorage.ts index 4a385ff8..e93eeda7 100644 --- a/src/lib/plan/planTravelLocalStorage.ts +++ b/src/lib/plan/planTravelLocalStorage.ts @@ -8,13 +8,15 @@ function roundCoord(n: number): string { const NS_ROUTE = "hau:plan:route:v1"; +/** 구간 = 출발 항목 → 도착 항목. 도착 항목이 없던 예전 형식(`…:출발:수단`)은 이 key와 겹치지 않아 읽지 않는다 */ function routeStorageKey( roomId: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, travelMode: string, ): string { - return `${NS_ROUTE}:${roomId.trim()}:${scheduleId}:${segmentSourceItemId}:${travelMode.trim()}`; + return `${NS_ROUTE}:${roomId.trim()}:${scheduleId}:${segmentSourceItemId}:${destinationItemId}:${travelMode.trim()}`; } type PersistedRouteV1 = { @@ -75,21 +77,6 @@ function removeSessionStorageKeysMatchingPrefixes(prefixes: string[]): void { } } -/** 지정 구간(`segmentSourceItemId`)의 경로 sessionStorage만 제거 */ -export function clearPersistedScheduleRoutesForSegmentSources( - roomId: string, - scheduleId: number, - segmentSourceItemIds: number[], -): void { - const rid = roomId.trim(); - if (!rid.length || segmentSourceItemIds.length === 0) return; - removeSessionStorageKeysMatchingPrefixes( - segmentSourceItemIds.map( - (id) => `${NS_ROUTE}:${rid}:${scheduleId}:${id}:`, - ), - ); -} - /** 해당 일차 경로 sessionStorage 항목 일괄 제거 */ export function clearPersistedScheduleRoutesForSchedule( roomId: string, @@ -110,13 +97,14 @@ export function readScheduleRouteFromSessionStorage( roomId: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, travelMode: string, expectedFp: string, ): ScheduleItemRouteResponse | null | undefined { if (typeof window === "undefined" || !expectedFp) return undefined; try { const raw = sessionStorage.getItem( - routeStorageKey(roomId, scheduleId, segmentSourceItemId, travelMode), + routeStorageKey(roomId, scheduleId, segmentSourceItemId, destinationItemId, travelMode), ); if (!raw) return undefined; const o = JSON.parse(raw) as PersistedRouteV1; @@ -133,6 +121,7 @@ export function writeScheduleRouteToSessionStorage( roomId: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, travelMode: string, fp: string, data: ScheduleItemRouteResponse | null, @@ -143,6 +132,7 @@ export function writeScheduleRouteToSessionStorage( roomId, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, ); if (data === null) { diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index 3b8d1c24..ab2f628b 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -16,6 +16,9 @@ vi.mock("@/lib/api/rooms/schedule-items", async (importOriginal) => { let hydrateScheduleRoutesBatch: typeof import("@/lib/plan/schedule-bulk-hydration").hydrateScheduleRoutesBatch; let fetchAndSeedScheduleRoutesBatch: typeof import("@/lib/plan/schedule-bulk-hydration").fetchAndSeedScheduleRoutesBatch; let resolveScheduleSegmentRoute: typeof import("@/lib/plan/scheduleSegmentRoute").resolveScheduleSegmentRoute; +let persistedScheduleItemRouteQueryOptions: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").persistedScheduleItemRouteQueryOptions; +let loadScheduleItemRouteForMap: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").loadScheduleItemRouteForMap; +let flattenPlanItinerarySegmentsFromPlaces: typeof import("@/lib/plan/planItineraryMapSegments").flattenPlanItinerarySegmentsFromPlaces; let scheduleItemsQueryKey: typeof import("@/lib/query-keys").scheduleItemsQueryKey; let scheduleItemRouteQueryKey: typeof import("@/lib/query-keys").scheduleItemRouteQueryKey; let registerQueryClient: typeof import("@/lib/query-client").registerQueryClient; @@ -47,6 +50,12 @@ beforeAll(async () => { ({ resolveScheduleSegmentRoute } = await import( "@/lib/plan/scheduleSegmentRoute" )); + ({ persistedScheduleItemRouteQueryOptions, loadScheduleItemRouteForMap } = await import( + "@/lib/plan/scheduleItemRoutePersistedQuery" + )); + ({ flattenPlanItinerarySegmentsFromPlaces } = await import( + "@/lib/plan/planItineraryMapSegments" + )); ({ scheduleItemsQueryKey, scheduleItemRouteQueryKey } = await import( "@/lib/query-keys" )); @@ -67,13 +76,14 @@ describe("hydrateScheduleRoutesBatch", () => { const routeResult = { status: "OK" as const, itemId: 1, + toItemId: 2, travelMode: "DRIVING", distanceMeters: 1200, durationSeconds: 300, encodedPolyline: "_p~iF~ps|U_ulLnnqC", }; - it("응답 시점에 일정 지문이 그대로면 경로를 시딩한다", async () => { + it("응답이 알려 준 출발·도착 항목 구간으로 경로를 시딩한다", async () => { const queryClient = new QueryClient(); queryClient.setQueryData( scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), @@ -85,7 +95,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), ), ).toMatchObject({ distanceMeters: 1200, @@ -125,7 +135,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), ), ).not.toHaveProperty("encodedPolyline"); }); @@ -147,6 +157,7 @@ describe("hydrateScheduleRoutesBatch", () => { { status: "ERROR", itemId: 2, + toItemId: 3, travelMode: "WALKING", errorCode: "UPSTREAM_TIMEOUT", }, @@ -171,6 +182,7 @@ describe("hydrateScheduleRoutesBatch", () => { ROOM_ID, SCHEDULE_ID, 2, + 3, "WALKING", ); expect(queryClient.getQueryData(failedKey)).toBeUndefined(); @@ -179,6 +191,7 @@ describe("hydrateScheduleRoutesBatch", () => { roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, + destinationItemId: 3, travelMode: "WALKING", }), ).rejects.toThrow("temporary single route failure"); @@ -188,14 +201,42 @@ describe("hydrateScheduleRoutesBatch", () => { roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, + destinationItemId: 3, travelMode: "WALKING", }), ).resolves.toEqual(fallbackRoute); expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(2); }); - it.each(["NO_ROUTE", "LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"])( - "경로 없음 답(%s)은 null로 시딩해 단건으로 다시 묻지 않는다", + it("경로 없음 답(NO_ROUTE)은 그 구간을 null로 시딩해 단건으로 다시 묻지 않는다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const places = [place(1, "places/a"), place(2, "places/b"), place(3, "places/c")]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); + getScheduleItemRoutesBatchMock.mockResolvedValue([ + routeResult, + { status: "FAILED", itemId: 2, toItemId: 3, travelMode: "WALKING", errorCode: "NO_ROUTE" }, + ]); + + await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + expect( + queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")), + ).toBeNull(); + await expect( + resolveScheduleSegmentRoute({ + roomId: ROOM_ID, + scheduleId: SCHEDULE_ID, + segmentSourceItemId: 2, + destinationItemId: 3, + travelMode: "WALKING", + }), + ).resolves.toBeNull(); + expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + }); + + it.each(["LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"])( + "도착 항목이 없는 답(%s)은 시딩하지 않고 그 칸이 도착 항목을 지정한 단건으로 받는다", async (errorCode) => { const queryClient = new QueryClient(); registerQueryClient(queryClient); @@ -205,32 +246,35 @@ describe("hydrateScheduleRoutesBatch", () => { routeResult, { status: "FAILED", itemId: 2, travelMode: "WALKING", errorCode }, ]); + const toThree = { travelMode: "WALKING", distanceMeters: 800, durationSeconds: 600 }; + getScheduleItemRouteMock.mockResolvedValue(toThree); await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); expect( - queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "WALKING")), - ).toBeNull(); + queryClient.getQueryState(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")), + ).toBeUndefined(); await expect( resolveScheduleSegmentRoute({ roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, + destinationItemId: 3, travelMode: "WALKING", }), - ).resolves.toBeNull(); - expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + ).resolves.toEqual(toThree); + expect(getScheduleItemRouteMock).toHaveBeenCalledExactlyOnceWith(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING"); }, ); - it("응답 대기 중 일정이 바뀌면(지문 불일치) 늦게 도착한 결과를 버린다", async () => { + it("응답 대기 중 순서가 바뀌어도 응답이 알려 준 구간에 저장하고 새 순서의 구간에는 넣지 않는다", async () => { const queryClient = new QueryClient(); queryClient.setQueryData( scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), snapshot, ); getScheduleItemRoutesBatchMock.mockImplementation(async () => { - // batch 응답이 도착하기 전에 목적지가 B → C 로 교체된 상황 + // batch 응답이 도착하기 전에 1번 다음 장소가 2 → 3으로 바뀐 상황 — 서버는 요청을 받은 순간의 1→2를 계산했다 queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), [ place(1, "places/a"), place(3, "places/c"), @@ -242,7 +286,12 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), + ), + ).toMatchObject({ distanceMeters: 1200 }); + expect( + queryClient.getQueryState( + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 3, "DRIVING"), ), ).toBeUndefined(); }); @@ -255,8 +304,8 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { function seededClient() { const queryClient = new QueryClient(); queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); - queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), route); - queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "DRIVING"), route); + queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), route); + queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "DRIVING"), route); return queryClient; } @@ -268,7 +317,7 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { it("캐시에 없는 구간이 있으면 일괄로 다시 받는다", async () => { const queryClient = seededClient(); - queryClient.removeQueries({ queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "DRIVING") }); + queryClient.removeQueries({ queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "DRIVING") }); getScheduleItemRoutesBatchMock.mockResolvedValue([]); await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); @@ -276,10 +325,23 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); }); + it("순서를 바꿨다가 되돌리면 받아 둔 경로가 있어 일괄 요청을 보내지 않는다", async () => { + const queryClient = seededClient(); + const reordered = [place(1, "places/a"), place(3, "places/c"), place(2, "places/b")]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), reordered); + getScheduleItemRoutesBatchMock.mockResolvedValue([]); + + await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, reordered); + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); + await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); + }); + it("일정 변경으로 무효화된 구간이 있으면 일괄로 다시 받는다", async () => { const queryClient = seededClient(); await queryClient.invalidateQueries({ - queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), refetchType: "none", }); getScheduleItemRoutesBatchMock.mockResolvedValue([]); @@ -289,3 +351,65 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); }); }); + +describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 쓴다", () => { + const places = [place(1, "places/a"), place(2, "places/b"), place(3, "places/c")]; + const batchRoute = (itemId: number, distanceMeters: number) => ({ + status: "OK" as const, + itemId, + toItemId: itemId + 1, + travelMode: "DRIVING", + distanceMeters, + durationSeconds: 60, + encodedPolyline: "_p~iF~ps|U_ulLnnqC", + }); + + it("진입 batch가 받아 둔 경로를 목록 칸과 지도가 다시 요청하지 않고 쓴다", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); + getScheduleItemRoutesBatchMock.mockResolvedValue([batchRoute(1, 100), batchRoute(2, 200)]); + + await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + // 목록 칸 — PlanItinerary가 넘기는 것처럼 다음 장소의 itemId로 조회한다 + const listDistances = await Promise.all( + places.slice(0, -1).map((origin, index) => + queryClient + .fetchQuery( + persistedScheduleItemRouteQueryOptions( + ROOM_ID, + SCHEDULE_ID, + origin.itemId ?? null, + places[index + 1]?.itemId ?? null, + "DRIVING", + "", + { segmentReady: true }, + ), + ) + .then((route) => route?.distanceMeters), + ), + ); + // 지도 — 지도가 쓰는 구간 계산 그대로 조회한다 + const mapDistances = await Promise.all( + flattenPlanItinerarySegmentsFromPlaces([SCHEDULE_ID], [places]).map((segment) => + loadScheduleItemRouteForMap( + queryClient, + persistedScheduleItemRouteQueryOptions( + ROOM_ID, + segment.scheduleId, + segment.segmentSourceItemId, + segment.destinationItemId, + segment.travelModeCanon, + "", + { segmentReady: true }, + ), + ).then((route) => route?.distanceMeters), + ), + ); + + expect(listDistances).toEqual([100, 200]); + expect(mapDistances).toEqual([100, 200]); + expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); + expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index e33aeab5..47484427 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -18,13 +18,6 @@ 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 { canonicalScheduleTravelMode, } from "@/lib/plan/scheduleTravelMode"; @@ -208,7 +201,7 @@ export async function buildSchedulePlacesFromSchedulesWithItems( } /** - * 만든 일차 목록을 한 번에 씁니다. 순서가 바뀐 일차는 이웃이 바뀐 구간 경로만 무효화합니다. + * 만든 일차 목록을 한 번에 씁니다. 경로는 도착 항목까지 key에 있어 순서가 바뀌어도 무효화하지 않습니다. * `startedAt`(일정 전체 조회를 보낸 시각) 뒤에 바뀐 일차는 그쪽이 더 새 상태라 덮지 않고, * 그 일차를 받는 중이면 이 조회보다 먼저 보낸 요청일 수 있어 다시 받게 합니다. */ @@ -226,20 +219,10 @@ export function writeSchedulePlacesFromHydrate( const state = queryClient.getQueryState(key); if (state && state.dataUpdatedAt > startedAt) continue; - const before = readOrderedItemIdsFromScheduleItemsCache(queryClient, rid, sid); queryClient.setQueryData(key, places); if (state?.fetchStatus === "fetching") { void queryClient.refetchQueries({ queryKey: key, exact: true }); } - - const changed = segmentSourcesWithChangedNext( - before, - orderedItemIdsFromPlanPlaces(places), - ); - if (changed.length) { - void invalidateScheduleItemRouteForSources(queryClient, rid, sid, changed); - bumpPlanMapRouteSegments(rid, sid, changed); - } } } @@ -277,8 +260,6 @@ const inFlightScheduleRoutesBatch = new Map>(); * 실패가 아니라 "경로 없음"이라는 답인 batch 항목 — 단건으로 다시 물어도 같은 답(204·404)이 오므로 * 경로 없음(`null`)으로 시딩하고 다시 묻지 않는다. */ -const ROUTE_ANSWER_ERROR_CODES = new Set(["NO_ROUTE", "LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"]); - /** * in-flight dedup 키 — 일정 지문(`itemId` 순서·장소 정체성)을 포함해 * 목적지만 바뀐 일정(`[A,B]`→`[A,C]`)이 이전 batch Promise를 공유하지 않게 합니다. @@ -291,9 +272,26 @@ function scheduleRoutesBatchKey( return `${roomId.trim()}:${scheduleId}:${placesFingerprint}`; } +/** 구간 출발 항목 → 다음 장소의 `itemId` (마지막 장소와 항목 ID가 없는 구간은 뺀다) */ +function segmentDestinationsByItemId(places: readonly PlanPlace[]): Map { + const out = new Map(); + for (let i = 0; i < places.length - 1; i += 1) { + const itemId = places[i]?.itemId; + const destination = places[i + 1]?.itemId; + if (isFiniteItemId(itemId) && isFiniteItemId(destination)) { + out.set(itemId, destination); + } + } + return out; +} + +function isFiniteItemId(id: number | undefined): id is number { + return typeof id === "number" && Number.isFinite(id); +} + /** * 일차의 모든 구간 경로가 이미 캐시에 있는지 — 일정 화면에서 받아 뒀거나, 화면을 다시 열었거나, - * 지도 경로 보기로 넘어온 경우다. 캐시가 지워졌거나(gc) 일정 변경으로 무효화된 구간이 하나라도 있으면 false. + * 지도 경로 보기로 넘어온 경우다. 받은 적 없거나(새 순서·gc) 무효화된 구간이 하나라도 있으면 false. */ export function scheduleSegmentRoutesCached( queryClient: QueryClient, @@ -302,14 +300,17 @@ export function scheduleSegmentRoutesCached( places: readonly PlanPlace[], ): boolean { const rid = roomId.trim(); - const sources = places - .slice(0, -1) - .filter((p): p is PlanPlace & { itemId: number } => typeof p.itemId === "number" && Number.isFinite(p.itemId)); - if (!rid.length || !sources.length) return false; - return sources.every((place) => { + const destinationByItemId = segmentDestinationsByItemId(places); + if (!rid.length || !destinationByItemId.size) return false; + return places.every((place) => { + if (typeof place.itemId !== "number") return true; + const destination = destinationByItemId.get(place.itemId); + if (destination === undefined) return true; const mode = canonicalScheduleTravelMode(place.travelMode); if (!mode) return true; - const state = queryClient.getQueryState(scheduleItemRouteQueryKey(rid, scheduleId, place.itemId, mode)); + const state = queryClient.getQueryState( + scheduleItemRouteQueryKey(rid, scheduleId, place.itemId, destination, mode), + ); return state?.status === "success" && !state.isInvalidated; }); } @@ -390,27 +391,18 @@ export async function hydrateScheduleRoutesBatch( const results = await getScheduleItemRoutesBatch(rid, scheduleId, requestItems); - /** - * 응답 대기 중 일정이 바뀌었으면(지문 불일치) 늦게 도착한 결과를 버립니다 — - * 구간 캐시 키는 출발 항목만 담고 있어 옛 목적지의 경로가 새 구간에 시딩될 수 있음. - */ - const snapshotFp = schedulePlacesFingerprint([...places]); - const currentPlaces = queryClient.getQueryData( - scheduleItemsQueryKey(rid, scheduleId), - ); - const currentFp = schedulePlacesFingerprint([...(currentPlaces ?? [])]); - if (!snapshotFp.length || snapshotFp !== currentFp) return; - + // 서버는 요청을 받은 순간의 순서로 다음 항목을 정하므로, 응답이 알려 준 도착 항목(toItemId)의 구간으로 저장한다 — + // 기다리는 사이 순서가 바뀌었어도 그 구간의 값이라 버리지 않는다. 마지막 항목·항목 없음은 도착 항목이 없어 + // 저장하지 않고, 그 칸이 도착 항목을 지정한 단건 조회로 받는다 for (const result of results) { - const itemId = result.itemId; + const { itemId, toItemId } = result; const mode = canonicalScheduleTravelMode(result.travelMode); - if (!mode) continue; - if (typeof itemId !== "number" || !Number.isFinite(itemId)) continue; + if (!mode || !isFiniteItemId(itemId) || !isFiniteItemId(toItemId)) continue; - const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, mode); + const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, toItemId, mode); if (isBatchItemOk(result)) { queryClient.setQueryData(routeKey, batchRouteItemToResponse(result)); - } else if (result.errorCode && ROUTE_ANSWER_ERROR_CODES.has(result.errorCode)) { + } else if (result.errorCode === "NO_ROUTE") { queryClient.setQueryData(routeKey, null); } } diff --git a/src/lib/plan/scheduleItemPlaces.create.test.ts b/src/lib/plan/scheduleItemPlaces.create.test.ts index 88b33283..1c3eca3c 100644 --- a/src/lib/plan/scheduleItemPlaces.create.test.ts +++ b/src/lib/plan/scheduleItemPlaces.create.test.ts @@ -9,17 +9,10 @@ import type { PlanPlace } from "@/lib/plan/types"; import { scheduleItemsQueryKey } from "@/lib/query-keys"; const mocks = vi.hoisted(() => ({ - bumpForDirections: vi.fn(), - bumpPlanMapRouteSegments: vi.fn(), createScheduleItem: vi.fn(), updateScheduleItem: vi.fn(), fetchPlacePreview: vi.fn(), getScheduleItems: vi.fn(), - invalidateScheduleItemRouteForSources: vi.fn(), - invalidateScheduleItemRouteForWholeSchedule: vi.fn(), - invalidateScheduleItemRoutesAfterDelete: vi.fn(), - invalidateScheduleItemRoutesAfterItemCreated: vi.fn(), - readOrderedItemIdsFromScheduleItemsCache: vi.fn(), })); vi.mock("@/lib/api/rooms/schedule-items", () => ({ @@ -32,22 +25,6 @@ vi.mock("@/lib/places/place-queries", () => ({ fetchPlacePreview: mocks.fetchPlacePreview, })); -vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ - bumpPlanMapRouteSegments: mocks.bumpPlanMapRouteSegments, - invalidateScheduleItemRouteForSources: - mocks.invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule: - mocks.invalidateScheduleItemRouteForWholeSchedule, - invalidateScheduleItemRoutesAfterDelete: - mocks.invalidateScheduleItemRoutesAfterDelete, - invalidateScheduleItemRoutesAfterItemCreated: - mocks.invalidateScheduleItemRoutesAfterItemCreated, - invalidateScheduleItemRoutesAfterReorder: vi.fn(), - readOrderedItemIdsFromScheduleItemsCache: - mocks.readOrderedItemIdsFromScheduleItemsCache, - removeRouteQueriesForDeletedItemSource: vi.fn(), -})); - vi.mock("@/lib/rooms", () => ({ awaitRoomSchedulesHydrated: vi.fn(), })); @@ -56,12 +33,6 @@ vi.mock("@/lib/query-client", () => ({ getQueryClient: vi.fn(), })); -vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ - usePlanMapDirectionsEpochStore: { - getState: () => ({ bumpForDirections: mocks.bumpForDirections }), - }, -})); - let createScheduleItemAtPlanIndex: typeof import("@/lib/plan/scheduleItemPlaces").createScheduleItemAtPlanIndex; let applyCreatedScheduleItemToPlanPlaces: typeof import("@/lib/plan/scheduleItemPlaces").applyCreatedScheduleItemToPlanPlaces; let syncAfterCrossScheduleItemMove: typeof import("@/lib/plan/scheduleItemPlaces").syncAfterCrossScheduleItemMove; @@ -97,10 +68,6 @@ describe("createScheduleItemAtPlanIndex", () => { name: "Created place", formattedAddress: "Address", }); - mocks.invalidateScheduleItemRouteForSources.mockResolvedValue(undefined); - mocks.invalidateScheduleItemRoutesAfterItemCreated.mockResolvedValue( - undefined, - ); }); const response: CreateScheduleItemResponse = { @@ -132,7 +99,7 @@ describe("createScheduleItemAtPlanIndex", () => { }); }); - it("merges the create response and invalidates only the route IDs it returns", async () => { + it("merges the create response into the day list", async () => { const queryClient = new QueryClient(); const key = scheduleItemsQueryKey("room-1", 10); queryClient.setQueryData(key, places); @@ -142,82 +109,27 @@ describe("createScheduleItemAtPlanIndex", () => { expect( queryClient.getQueryData(key)?.map((place) => place.itemId), ).toEqual([1, 3, 2]); - expect( - mocks.invalidateScheduleItemRoutesAfterItemCreated, - ).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [777], - ); - expect(mocks.bumpPlanMapRouteSegments).toHaveBeenCalledWith( - "room-1", - 10, - [777], - ); }); - it("does not fail the saved create when post-create route synchronization fails", async () => { + it("merges a placeholder place when the created place preview fails", async () => { const queryClient = new QueryClient(); - mocks.invalidateScheduleItemRouteForSources.mockRejectedValueOnce( - new Error("route sync failed"), - ); - queryClient.setQueryData(scheduleItemsQueryKey("room-1", 10), places); + const key = scheduleItemsQueryKey("room-1", 10); + mocks.fetchPlacePreview.mockRejectedValueOnce(new Error("preview 429")); + queryClient.setQueryData(key, places); await expect( applyCreatedScheduleItemToPlanPlaces(queryClient, "room-1", 10, response), ).resolves.toBeUndefined(); - expect( - mocks.invalidateScheduleItemRouteForWholeSchedule, - ).toHaveBeenCalledWith(queryClient, "room-1", 10); - expect(mocks.bumpForDirections).toHaveBeenCalledWith("room-1"); - }); - - it("maps server-provided move route IDs to the refreshed schedules", async () => { - const queryClient = new QueryClient(); - mocks.getScheduleItems.mockResolvedValue([]); - mocks.readOrderedItemIdsFromScheduleItemsCache.mockImplementation( - (_queryClient: QueryClient, _roomId: string, scheduleId: number) => - scheduleId === 10 ? [11] : [20, 21], - ); - - await syncAfterCrossScheduleItemMove( - queryClient, - "room-1", - 10, - 20, - 20, - [11, 21, 20], - ); - - expect(mocks.invalidateScheduleItemRoutesAfterDelete).not.toHaveBeenCalled(); - expect( - mocks.invalidateScheduleItemRoutesAfterItemCreated, - ).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenNthCalledWith( - 1, - queryClient, - "room-1", - 10, - [11], - ); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenNthCalledWith( - 2, - queryClient, - "room-1", - 20, - [21, 20], - ); - expect( - mocks.invalidateScheduleItemRouteForWholeSchedule, - ).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(key)?.[1]).toMatchObject({ + itemId: 3, + title: "장소 정보를 불러올 수 없음", + }); }); }); describe("cross-schedule refresh sequencing", () => { - it("waits for source refresh before target and routes", async () => { + it("waits for source refresh before target", async () => { const client = new QueryClient(); let finishSource!: () => void; const source = new Promise((resolve) => { finishSource = resolve; }); @@ -235,13 +147,11 @@ describe("cross-schedule refresh sequencing", () => { await syncAfterCrossScheduleItemMove(client, "room-1", 10, 10); expect(invalidate).toHaveBeenCalledTimes(1); }); - it("does not refresh target or routes when source refresh fails", async () => { - vi.clearAllMocks(); + it("does not refresh target when source refresh fails", async () => { const client = new QueryClient(); const invalidate = vi.spyOn(client, "invalidateQueries").mockRejectedValue(new Error("source failed")); await expect(syncAfterCrossScheduleItemMove(client, "room-1", 10, 20)).rejects.toThrow("source failed"); expect(invalidate).toHaveBeenCalledTimes(1); - expect(mocks.invalidateScheduleItemRouteForWholeSchedule).not.toHaveBeenCalled(); }); }); @@ -258,7 +168,7 @@ describe("reorder preserves user times", () => { { ...scheduleItem(2, 0), startTime, endTime }, { ...scheduleItem(1, 1), startTime: null, endTime: null }, ]; - await syncPlanPlacesAfterReorderSuccess(client, "room", 10, items, 1); + await syncPlanPlacesAfterReorderSuccess(client, "room", 10, items); expect(mocks.updateScheduleItem).not.toHaveBeenCalled(); expect(client.getQueryData>(key)?.map(p => [p.itemId, p.startTime ?? null, p.endTime ?? null])).toEqual([[2, startTime, endTime], [1, null, null]]); @@ -280,7 +190,7 @@ it("cross-day move refresh preserves unset and partial times without a time PATC new QueryObserver(client, schedulePlanPlacesQueryOptions(client, "room", scheduleId)) .subscribe(() => {}), ); - await syncAfterCrossScheduleItemMove(client, "room", 10, 20, 2, []); + await syncAfterCrossScheduleItemMove(client, "room", 10, 20); expect(mocks.updateScheduleItem).not.toHaveBeenCalled(); expect(client.getQueryData>(scheduleItemsQueryKey("room", 10))?.[0]).not.toHaveProperty("startTime"); expect(client.getQueryData>(scheduleItemsQueryKey("room", 20))?.[0]).toMatchObject({ startTime: "12:45" }); diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index ae294e75..b087ce82 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -20,19 +20,8 @@ import { resolveScheduleItemsFromCacheOrHydrate, } from "@/lib/plan/schedule-bulk-hydration"; import { getQueryClient } from "@/lib/query-client"; -import { - bumpPlanMapRouteSegments, - invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule, - invalidateScheduleItemRoutesAfterDelete, - invalidateScheduleItemRoutesAfterItemCreated, - invalidateScheduleItemRoutesAfterReorder, - readOrderedItemIdsFromScheduleItemsCache, - removeRouteQueriesForDeletedItemSource, -} from "@/lib/plan/scheduleStompRouteScope"; import { scheduleItemsQueryKey } from "@/lib/query-keys"; import type { PlanPlace } from "@/lib/plan/types"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; /** `orderIndex` 기준 정렬(비변형) */ export function sortRoomScheduleItemsByOrder( @@ -185,65 +174,24 @@ export function removeScheduleItemFromPlanPlacesCache( ); } -/** 본인 DELETE mutation 직후 — 캐시 패치·삭제 구간 route·쿼리 정리 */ -export async function applyScheduleItemDeletedOnClient( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - deletedItemId: number, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - - const oldIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - scheduleId, - ); - removeScheduleItemFromPlanPlacesCache( - queryClient, - rid, - scheduleId, - deletedItemId, - ); - removeRouteQueriesForDeletedItemSource( - queryClient, - rid, - scheduleId, - deletedItemId, - ); - await invalidateScheduleItemRoutesAfterDelete( - queryClient, - rid, - scheduleId, - deletedItemId, - oldIds, - ); -} - /** * STOMP·뮤테이션 동기화: 그 일차 장소 목록을 서버에서 다시 받게 합니다. 보는 화면이 있으면 바로 받고, * 없으면 다시 받을 목록으로 표시해 두었다가 그 화면에 들어갈 때 받습니다. * 같은 일차를 받는 중이면 그 조회는 이 변경 전에 보낸 요청이라 취소하고 새로 받습니다(늦게 온 옛 응답은 버려짐). - * 본인 저장의 반향처럼 받는 중인 조회가 이미 그 변경 뒤에 보낸 것이면 `joinInFlight`로 합류해 요청을 아낍니다. - * 다시 받지 못하면 throw합니다 — 호출부는 목록이 바뀐 뒤에만 경로를 무효화합니다. + * 다시 받지 못하면 throw합니다. */ export async function invalidateSchedulePlanPlaces( queryClient: QueryClient, roomId: string, scheduleId: number, - { joinInFlight = false }: { joinInFlight?: boolean } = {}, ): Promise { const queryKey = scheduleItemsQueryKey(roomId.trim(), scheduleId); // 목록이 아직 없으면 TanStack은 진행 중인 첫 조회를 취소하지 않고 합류만 한다 const state = queryClient.getQueryState(queryKey); - if (!joinInFlight && state?.data === undefined && state?.fetchStatus === "fetching") { + if (state?.data === undefined && state?.fetchStatus === "fetching") { await queryClient.cancelQueries({ queryKey, exact: true }); } - await queryClient.invalidateQueries( - { queryKey, exact: true }, - { throwOnError: true, cancelRefetch: !joinInFlight }, - ); + await queryClient.invalidateQueries({ queryKey, exact: true }, { throwOnError: true }); } /** @@ -266,98 +214,23 @@ export async function mergeOrRefetchSchedulePlanPlacesFromItems( ); } -/** cross-day item 이동 후 source/target 일차 items·route 캐시 동기화 */ +/** + * 다른 일차로 옮긴 뒤 두 일차 장소 목록을 다시 받습니다. 경로는 도착 항목까지 key에 있어 + * 순서가 바뀐 구간은 새 key로 받으므로 따로 무효화하지 않습니다. + */ export async function syncAfterCrossScheduleItemMove( queryClient: QueryClient, roomId: string, sourceScheduleId: number, targetScheduleId: number, - movedItemId?: number, - affectedRouteItemIds?: number[] | null, ): Promise { const rid = roomId.trim(); if (!rid.length) return; - const scheduleIds = - sourceScheduleId === targetScheduleId - ? [sourceScheduleId] - : [sourceScheduleId, targetScheduleId]; - - const oldSourceOrderedIds = - movedItemId != null - ? readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - sourceScheduleId, - ) - : null; - await invalidateSchedulePlanPlaces(queryClient, rid, sourceScheduleId); if (targetScheduleId !== sourceScheduleId) { await invalidateSchedulePlanPlaces(queryClient, rid, targetScheduleId); } - - if (affectedRouteItemIds !== undefined) { - if (affectedRouteItemIds === null) { - for (const sid of scheduleIds) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - } - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - - const remaining = new Set(affectedRouteItemIds); - for (const sid of scheduleIds) { - const orderedIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - sid, - ); - const scheduleItemIds = new Set(orderedIds ?? []); - const sources = affectedRouteItemIds.filter( - (id) => remaining.has(id) && scheduleItemIds.has(id), - ); - for (const id of sources) remaining.delete(id); - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - sid, - sources, - ); - bumpPlanMapRouteSegments(rid, sid, sources); - } - - if (remaining.size > 0) { - for (const sid of scheduleIds) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - } - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - } - return; - } - - if (movedItemId != null) { - await invalidateScheduleItemRoutesAfterDelete( - queryClient, - rid, - sourceScheduleId, - movedItemId, - oldSourceOrderedIds, - ); - await invalidateScheduleItemRoutesAfterItemCreated( - queryClient, - rid, - targetScheduleId, - movedItemId, - ); - return; - } - - for (const sid of scheduleIds) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - } - - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); } /** 캐시에 preview 에러 title이 있으면 enrich 후 `schedule-items`를 갱신합니다. */ @@ -475,38 +348,22 @@ export async function fetchSchedulePlanPlacesFromCacheOrApi( return fetchScheduleItemsAsPlanPlaces(rid, scheduleId); } -/** 순서 응답을 반영하고 변경된 구간의 경로 캐시만 무효화합니다. */ +/** 순서 응답을 반영합니다. 경로 캐시는 출발·도착 항목 키로 구분합니다. */ export async function syncPlanPlacesAfterReorderSuccess( queryClient: QueryClient, roomId: string, scheduleId: number, items: RoomScheduleItem[], - movedItemId: number, ): Promise { const rid = roomId.trim(); if (!rid.length) return; - const oldOrderedIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - scheduleId, - ); - await mergeOrRefetchSchedulePlanPlacesFromItems( queryClient, roomId, scheduleId, items, ); - - await invalidateScheduleItemRoutesAfterReorder( - queryClient, - rid, - scheduleId, - movedItemId, - oldOrderedIds, - items, - ); } /** PATCH 응답 한 건으로 로컬 PlanPlace 캐시만 갱신(같은 목록에 itemId가 있을 때). */ @@ -636,37 +493,20 @@ export function createScheduleItemAtPlanIndex(args: { }); } -/** - * 장소 추가 응답(추가된 항목·순서가 밀린 항목)을 일차 목록에 합치고, 응답이 알려 준 구간 경로만 무효화합니다. - * 추가는 이미 저장됐으므로 반영이 실패해도 던지지 않고 그 일차 경로를 모두 무효화합니다. - */ +/** 장소 추가 응답(추가된 항목·순서가 밀린 항목)을 일차 목록에 합칩니다. */ export async function applyCreatedScheduleItemToPlanPlaces( queryClient: QueryClient, roomId: string, scheduleId: number, response: CreateScheduleItemResponse, ): Promise { - const rid = roomId.trim(); - try { - const createdPlace = await planPlaceFromScheduleItem(response.createdItem); - // 미리보기를 기다리는 사이 다른 사람의 변경을 받았을 수 있어 그 뒤의 목록에 합친다 - queryClient.setQueryData( - scheduleItemsQueryKey(rid, scheduleId), - (old) => mergeCreatedScheduleItemDelta(old, createdPlace, response) ?? old, - ); - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - scheduleId, - response.affectedRouteItemIds, - ); - bumpPlanMapRouteSegments(rid, scheduleId, response.affectedRouteItemIds); - } catch { - await Promise.allSettled([ - invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId), - ]); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - } + // 미리보기를 못 받으면 자리표시자 장소로 합친다(planPlaceFromScheduleItem은 던지지 않음) + const createdPlace = await planPlaceFromScheduleItem(response.createdItem); + // 미리보기를 기다리는 사이 다른 사람의 변경을 받았을 수 있어 그 뒤의 목록에 합친다 + queryClient.setQueryData( + scheduleItemsQueryKey(roomId.trim(), scheduleId), + (old) => mergeCreatedScheduleItemDelta(old, createdPlace, response) ?? old, + ); } /** 드래그를 `toIndex` 자리에 놓았을 때 PATCH에 넣을 `newOrderIndex`(0-based) */ diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts index 65afd3ff..7171e8c4 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts @@ -42,6 +42,7 @@ describe("persistedScheduleItemRouteQueryOptions", () => { "room-1", 10, 1, + 2, "DRIVING", "", { segmentReady: true }, @@ -70,10 +71,34 @@ describe("persistedScheduleItemRouteQueryOptions", () => { }); }); +describe("persistedScheduleItemRouteQueryOptions key", () => { + it("도착 항목이 key에 들어가 같은 출발 항목이라도 도착 항목마다 다른 캐시를 쓴다", () => { + const toB = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 2, "DRIVING", "", { + segmentReady: true, + }); + const toC = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 3, "DRIVING", "", { + segmentReady: true, + }); + + expect(toB.queryKey).toEqual(["schedule-item-route", "room-1", 10, 1, 2, "DRIVING"]); + expect(toC.queryKey).not.toEqual(toB.queryKey); + }); + + it("도착 항목이 없으면 조회하지 않는다", () => { + const options = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, null, "DRIVING", "", { + segmentReady: true, + }); + + expect(options.enabled).toBe(false); + }); +}); + describe("loadScheduleItemRouteForMap", () => { const route = { travelMode: "TRANSIT", distanceMeters: 1700, durationSeconds: 1140 }; const routeOptions = () => - persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "TRANSIT", "", { segmentReady: true }); + persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 2, "TRANSIT", "", { + segmentReady: true, + }); it("목록 요청이 방금 실패한 구간은 다시 보내지 않고 가진 값을 쓴다", async () => { const queryClient = new QueryClient(); diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.ts index 35164178..297902e8 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.ts @@ -15,7 +15,7 @@ type RouteCached = ScheduleItemRouteResponse | null; /** * — `segmentReady`: 네트워크 패치·sessionStorage 시드 허용(`routeQueryEnabled`). 쿼리 키는 이것과 무관하게 - * `(room, schedule, 구간, 모드)`로 고정 — 일정 refetch 중 잠깐 false가 되어도 구간끼리 캐시를 공유하지 않음 + * `(room, schedule, 출발 항목, 도착 항목, 모드)`로 고정 — 일정 refetch 중 잠깐 false가 되어도 구간끼리 캐시를 공유하지 않음 * — `networkEnabled` 생략 시 `segmentReady`와 동일; 지연 수단은 메뉴 열림 시만 `true` */ export type PersistedScheduleRoutePersistFlags = { @@ -25,11 +25,13 @@ export type PersistedScheduleRoutePersistFlags = { networkEnabled?: boolean; }; -/** `GET …/route?travelMode=…` — sessionStorage(`scheduleFingerprint` 일치) ↔ React Query */ +/** `GET …/route?destinationItemId=…&travelMode=…` — sessionStorage(`scheduleFingerprint` 일치) ↔ React Query */ export function persistedScheduleItemRouteQueryOptions( roomIdTrimmed: string, scheduleId: number | null, segmentSourceItemId: number | null, + /** 다음 장소의 `itemId` — 없으면 구간이 정해지지 않아 조회하지 않는다 */ + destinationItemId: number | null, travelMode: ScheduleTravelModeValue | null, fingerprintTrimmed: string, flags: PersistedScheduleRoutePersistFlags, @@ -43,10 +45,13 @@ export function persistedScheduleItemRouteQueryOptions( const itemOk = typeof segmentSourceItemId === "number" && Number.isFinite(segmentSourceItemId); + const destOk = + typeof destinationItemId === "number" && + Number.isFinite(destinationItemId); const fp = fingerprintTrimmed.trim(); const keyEligible = - flags.segmentReady && travelMode !== null && rid.length > 0 && sidOk && itemOk; + flags.segmentReady && travelMode !== null && rid.length > 0 && sidOk && itemOk && destOk; const network = typeof flags.networkEnabled === "boolean" @@ -58,8 +63,8 @@ export function persistedScheduleItemRouteQueryOptions( const keyRoom = rid.length > 0 ? rid : null; const keySchedule = sidOk ? sid : null; const keySegment = itemOk ? segmentSourceItemId : null; - const keyMode = - sidOk && itemOk && rid.length > 0 ? travelMode : null; + const keyDestination = destOk ? destinationItemId : null; + const keyMode = sidOk && itemOk && destOk && rid.length > 0 ? travelMode : null; const sessionSeed: RouteCached | undefined = fp.length > 0 && @@ -70,6 +75,7 @@ export function persistedScheduleItemRouteQueryOptions( rid, sid!, segmentSourceItemId!, + destinationItemId!, travelMode, fp, ) @@ -80,6 +86,7 @@ export function persistedScheduleItemRouteQueryOptions( keyRoom, keySchedule, keySegment, + keyDestination, keyMode, ), ...(sessionSeed !== undefined @@ -93,6 +100,7 @@ export function persistedScheduleItemRouteQueryOptions( roomId: rid, scheduleId: sid!, segmentSourceItemId: segmentSourceItemId!, + destinationItemId: destinationItemId!, travelMode, scheduleFingerprint: fp, }), diff --git a/src/lib/plan/scheduleSegmentRoute.test.ts b/src/lib/plan/scheduleSegmentRoute.test.ts index 9fafad1a..ce240784 100644 --- a/src/lib/plan/scheduleSegmentRoute.test.ts +++ b/src/lib/plan/scheduleSegmentRoute.test.ts @@ -18,6 +18,7 @@ let getScheduleItemRouteMock: ReturnType; const ROOM_ID = "room-1"; const SCHEDULE_ID = 10; const ITEM_ID = 1; +const DESTINATION = 2; const ROUTE = { distanceMeters: 1200, @@ -50,14 +51,15 @@ beforeEach(() => { registerQueryClient(queryClient); }); -const ROUTE_KEY = () => - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, ITEM_ID, "DRIVING"); +const ROUTE_KEY = (destination = DESTINATION) => + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, ITEM_ID, destination, "DRIVING"); -function resolve() { +function resolve(destinationItemId = DESTINATION) { return resolveScheduleSegmentRoute({ roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: ITEM_ID, + destinationItemId, travelMode: "DRIVING", }); } @@ -70,11 +72,17 @@ describe("resolveScheduleSegmentRoute", () => { expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); }); - it("캐시 miss면 단건 GET 결과를 반환한다", async () => { + it("캐시 miss면 도착 항목을 지정한 단건 GET 결과를 반환한다", async () => { getScheduleItemRouteMock.mockResolvedValue(ROUTE); await expect(resolve()).resolves.toEqual(ROUTE); - expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); + expect(getScheduleItemRouteMock).toHaveBeenCalledExactlyOnceWith( + ROOM_ID, + SCHEDULE_ID, + ITEM_ID, + DESTINATION, + "DRIVING", + ); }); it("경로 없음(204)은 null을 반환한다", async () => { @@ -83,10 +91,16 @@ describe("resolveScheduleSegmentRoute", () => { await expect(resolve()).resolves.toBeNull(); }); - /** - * 재정렬로 무효화된 구간은 같은 키라도 목적지가 달라져 있습니다. - * 낡은 값을 그대로 돌려주면 지도에 이전 순서의 폴리라인이 남습니다. - */ + it("같은 출발 항목이라도 도착 항목이 다르면 그 도착 항목의 경로를 받고, 순서를 되돌리면 받아 둔 경로를 쓴다", async () => { + queryClient.setQueryData(ROUTE_KEY(2), ROUTE); + const toC = { ...ROUTE, distanceMeters: 999, encodedPolyline: "zzz" }; + getScheduleItemRouteMock.mockResolvedValue(toC); + + await expect(resolve(3)).resolves.toEqual(toC); + await expect(resolve(2)).resolves.toEqual(ROUTE); + expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); + }); + it("캐시가 무효화된 상태면 낡은 값을 쓰지 않고 다시 조회한다", async () => { queryClient.setQueryData(ROUTE_KEY(), ROUTE); await queryClient.invalidateQueries({ @@ -101,10 +115,7 @@ describe("resolveScheduleSegmentRoute", () => { expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); }); - /** - * 캐시 쓰기는 React Query가 소유합니다. 리졸버가 직접 setQueryData 하면 - * 무효화 플래그까지 지워져 재정렬 뒤 낡은 경로가 유효한 값으로 굳습니다. - */ + /** 캐시 쓰기는 이 리졸버를 queryFn으로 쓰는 쿼리가 맡는다 */ it("캐시에 직접 쓰지 않는다", async () => { getScheduleItemRouteMock.mockResolvedValue(ROUTE); diff --git a/src/lib/plan/scheduleSegmentRoute.ts b/src/lib/plan/scheduleSegmentRoute.ts index 6667d654..e943810a 100644 --- a/src/lib/plan/scheduleSegmentRoute.ts +++ b/src/lib/plan/scheduleSegmentRoute.ts @@ -13,24 +13,25 @@ export type ResolveScheduleSegmentRouteArgs = { roomId: string; scheduleId: number; segmentSourceItemId: number; + /** 다음 장소의 `itemId` — 서버가 그 순간의 순서와 관계없이 이 항목까지 계산한다 */ + destinationItemId: number; travelMode: ScheduleTravelModeValue; /** 일정 지문 — 비면 sessionStorage에 기록하지 않습니다. */ scheduleFingerprint?: string; }; /** - * 구간 `(room, schedule, 출발 항목, 이동수단)` 경로를 가져오는 fetch 본체입니다. + * 구간 `(room, schedule, 출발 항목, 도착 항목, 이동수단)` 경로를 가져오는 fetch 본체입니다. * 거리·소요 시간과 지도 폴리라인이 같은 캐시 항목을 공유하도록 일정 리스트와 지도가 모두 * 이 함수를 queryFn으로 쓰는 하나의 쿼리(`scheduleItemRouteQueryKey`)를 거칩니다. * - * in-flight 합치기와 재정렬 중 뒤늦게 도착한 응답 폐기는 React Query가 담당합니다. - * 여기서 직접 캐시에 쓰지 않습니다 — `setQueryData`는 무효화 플래그까지 지워서 - * 순서가 바뀐 뒤의 낡은 경로를 유효한 값으로 만들어 버립니다. + * in-flight 합치기와 캐시 쓰기는 이 함수를 queryFn으로 쓰는 쿼리(React Query)가 맡아, 여기서 직접 캐시에 쓰지 않습니다. */ export async function resolveScheduleSegmentRoute({ roomId, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, scheduleFingerprint, }: ResolveScheduleSegmentRouteArgs): Promise { @@ -44,6 +45,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, ); const cached = getQueryClient()?.getQueryState(routeKey); @@ -55,6 +57,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, ); @@ -64,6 +67,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, fp, fresh, diff --git a/src/lib/plan/scheduleStompRouteScope.test.ts b/src/lib/plan/scheduleStompRouteScope.test.ts deleted file mode 100644 index 08b4e576..00000000 --- a/src/lib/plan/scheduleStompRouteScope.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 8e69ea1d..00000000 --- a/src/lib/plan/scheduleStompRouteScope.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { QueryClient } from "@tanstack/react-query"; - -import type { RoomScheduleItem } from "@/lib/api/rooms/schedule-items"; -import { - clearPersistedScheduleRoutesForSchedule, - clearPersistedScheduleRoutesForSegmentSources, -} from "@/lib/plan/planTravelLocalStorage"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; -import type { PlanPlace } from "@/lib/plan/types"; -import { scheduleItemsQueryKey } from "@/lib/query-keys"; - -function asOrderedItemIds(places: unknown): number[] | null { - if (!Array.isArray(places)) return null; - const out: number[] = []; - for (const p of places) { - const row = p as PlanPlace; - if (typeof row?.itemId === "number" && Number.isFinite(row.itemId)) { - out.push(row.itemId); - } - } - return out.length > 0 || places.length === 0 ? out : null; -} - -/** `schedule-items` 캐시의 `PlanPlace[]`에서 순서대로 `itemId`를 뽑습니다. */ -export function readOrderedItemIdsFromScheduleItemsCache( - queryClient: QueryClient, - roomId: string, - scheduleId: number, -): number[] | null { - const data = queryClient.getQueryData( - scheduleItemsQueryKey(roomId, scheduleId), - ); - 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[], -): number[] { - return [...items] - .sort((a, b) => a.orderIndex - b.orderIndex) - .map((it) => it.itemId) - .filter((id): id is number => typeof id === "number" && Number.isFinite(id)); -} - -/** 항목 `itemId`에 인접한 구간의 출발 `segmentSourceItemId` (최대 prev, 자신). */ -function segmentSourcesTouchingItem( - orderedItemIds: number[], - itemId: number, -): Set { - const s = new Set(); - const i = orderedItemIds.indexOf(itemId); - if (i < 0) return s; - if (i > 0) s.add(orderedItemIds[i - 1]!); - if (i < orderedItemIds.length - 1) s.add(orderedItemIds[i]!); - return s; -} - -export function collectSegmentSourcesForCreate( - newOrderedIds: number[] | null, - itemId: number, -): { sources: number[]; useFallback: boolean } { - if (!newOrderedIds?.length) { - return { sources: [], useFallback: true }; - } - if (newOrderedIds.indexOf(itemId) < 0) { - return { sources: [], useFallback: true }; - } - return { - sources: [...segmentSourcesTouchingItem(newOrderedIds, itemId)], - useFallback: false, - }; -} - -export function removeRouteQueriesForDeletedItemSource( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - itemId: number, -): void { - const rid = roomId.trim(); - queryClient.removeQueries({ - predicate: (q) => { - const key = q.queryKey; - if (!Array.isArray(key) || key[0] !== "schedule-item-route") { - return false; - } - if (String(key[1] ?? "").trim() !== rid) return false; - if (key[2] !== scheduleId) return false; - return key[3] === itemId; - }, - }); -} - -export function collectSegmentSourcesForDelete( - oldOrderedIds: number[] | null, - deletedItemId: number, -): { sources: number[]; useFallback: boolean } { - if (!oldOrderedIds?.length) { - return { sources: [], useFallback: true }; - } - const i = oldOrderedIds.indexOf(deletedItemId); - if (i < 0) { - return { sources: [], useFallback: true }; - } - if (i === 0) { - return { sources: [], useFallback: false }; - } - return { sources: [oldOrderedIds[i - 1]!], useFallback: false }; -} - -export function collectSegmentSourcesForReorder( - oldOrderedIds: number[] | null, - newOrderedIds: number[] | null, - itemId: number, -): { sources: number[]; useFallback: boolean } { - if (!newOrderedIds?.length) { - return { sources: [], useFallback: true }; - } - if (newOrderedIds.indexOf(itemId) < 0) { - return { sources: [], useFallback: true }; - } - const s = new Set(segmentSourcesTouchingItem(newOrderedIds, itemId)); - if (oldOrderedIds?.length) { - segmentSourcesTouchingItem(oldOrderedIds, itemId).forEach((id) => - s.add(id), - ); - } - return { sources: [...s], useFallback: false }; -} - -export function bumpPlanMapRouteSegments( - roomId: string, - scheduleId: number, - sourceItemIds: number[], -): void { - if (sourceItemIds.length === 0) return; - const rid = roomId.trim(); - if (!rid.length) return; - usePlanMapDirectionsEpochStore.getState().bumpSegments( - rid, - sourceItemIds.map((segmentSourceItemId) => ({ - scheduleId, - segmentSourceItemId, - })), - ); -} - -/** 항목 삭제 직후 — 삭제 전 순서 스냅샷 기준 인접 구간 route만 무효화(본인 mutation). */ -export async function invalidateScheduleItemRoutesAfterDelete( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - deletedItemId: number, - oldOrderedIds: number[] | null, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const { sources, useFallback } = collectSegmentSourcesForDelete( - oldOrderedIds, - deletedItemId, - ); - if (useFallback) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - await invalidateScheduleItemRouteForSources(queryClient, rid, scheduleId, sources); - bumpPlanMapRouteSegments(rid, scheduleId, sources); -} - -/** 항목 생성 후 캐시 반영 직후 — 인접 구간 route만 무효화(본인 mutation). */ -export async function invalidateScheduleItemRoutesAfterItemCreated( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - itemId: number, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const orderedIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - scheduleId, - ); - const { sources, useFallback } = collectSegmentSourcesForCreate( - orderedIds, - itemId, - ); - if (useFallback) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - await invalidateScheduleItemRouteForSources(queryClient, rid, scheduleId, sources); - bumpPlanMapRouteSegments(rid, scheduleId, sources); -} - -/** - * 리오더·중간 삽입 직후 — STOMP보다 먼저 캐시·서버 순서를 알 때 구간만 무효화. - * `oldOrderedIds`는 merge 전 `schedule-items` 스냅샷, `items`는 리오더(또는 최종 PATCH) 응답. - */ -export async function invalidateScheduleItemRoutesAfterReorder( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - movedItemId: number, - oldOrderedIds: number[] | null, - items: RoomScheduleItem[], -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const newOrderedIds = orderedItemIdsFromRoomScheduleItems(items); - const { sources, useFallback } = collectSegmentSourcesForReorder( - oldOrderedIds, - newOrderedIds, - movedItemId, - ); - if (useFallback) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - await invalidateScheduleItemRouteForSources(queryClient, rid, scheduleId, sources); - bumpPlanMapRouteSegments(rid, scheduleId, sources); -} - -export async function invalidateScheduleItemRouteForSources( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - sourceItemIds: number[], -): Promise { - if (sourceItemIds.length === 0) return; - const rid = roomId.trim(); - clearPersistedScheduleRoutesForSegmentSources(rid, scheduleId, sourceItemIds); - const want = new Set(sourceItemIds); - await queryClient.invalidateQueries({ - predicate: (q) => { - const key = q.queryKey; - if (!Array.isArray(key) || key[0] !== "schedule-item-route") { - return false; - } - if (String(key[1] ?? "").trim() !== rid) return false; - if (key[2] !== scheduleId) return false; - return typeof key[3] === "number" && want.has(key[3]); - }, - /** `all`이면 옵저버 없는 캐시(재정렬로 구간이 사라진 뒤)도 refetch되어 모드 4개가 동시에 나감 */ - refetchType: "active", - }); -} - -export async function invalidateScheduleItemRouteForWholeSchedule( - queryClient: QueryClient, - roomId: string, - scheduleId: number, -): Promise { - const rid = roomId.trim(); - clearPersistedScheduleRoutesForSchedule(rid, scheduleId); - await queryClient.invalidateQueries({ - predicate: (q) => { - const key = q.queryKey; - if (!Array.isArray(key) || key[0] !== "schedule-item-route") { - return false; - } - if (String(key[1] ?? "").trim() !== rid) return false; - return key[2] === scheduleId; - }, - refetchType: "active", - }); -} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index dee035b1..e63a7b29 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -51,11 +51,10 @@ export function planItinerarySegmentMapPathQueryKey( rid: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, originPlaceId: string, destPlaceId: string, travelModeCanon: string, - roomDirectionsEpoch: number, - segmentEpoch: number, ) { return [ "plan-itinerary-map-segment-path", @@ -63,11 +62,10 @@ export function planItinerarySegmentMapPathQueryKey( rid, scheduleId, segmentSourceItemId, + destinationItemId, originPlaceId.trim(), destPlaceId.trim(), travelModeCanon, - roomDirectionsEpoch, - segmentEpoch, ] as const; } @@ -120,13 +118,15 @@ export const scheduleItemsQueryKey = ( ) => ["schedule-items", roomId, scheduleId, "v2-loc"] as const; /** - * 구간별 길찾기 API 결과 (`GET …/route?travelMode=…`). - * 무효화: `["schedule-item-route", roomId]` prefix. + * 구간별 길찾기 API 결과 (`GET …/route?destinationItemId=…&travelMode=…`). + * 요청에 보내는 출발·도착 항목과 이동수단이 곧 key라, 순서가 바뀌면 다른 key가 되고 + * 순서를 되돌리면 받아 둔 값을 그대로 쓴다. 일정이 바뀌어도 무효화하지 않는다. */ export const scheduleItemRouteQueryKey = ( roomId: string | null, scheduleId: number | null, segmentSourceItemId: number | null, + destinationItemId: number | null, travelMode: ScheduleTravelModeValue | null, ) => [ @@ -134,6 +134,7 @@ export const scheduleItemRouteQueryKey = ( roomId, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, ] as const; diff --git a/src/lib/stomp/bookmarks-dispatch.test.ts b/src/lib/stomp/bookmarks-dispatch.test.ts new file mode 100644 index 00000000..746d9166 --- /dev/null +++ b/src/lib/stomp/bookmarks-dispatch.test.ts @@ -0,0 +1,75 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { sessionUserQueryKey } from "@/lib/query-keys"; +import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp"; + +const mocks = vi.hoisted(() => ({ + invalidateRoomBookmarkQueries: vi.fn(), +})); + +vi.mock("@/lib/bookmarks-invalidation", () => ({ + invalidateRoomBookmarkQueries: mocks.invalidateRoomBookmarkQueries, +})); +vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); + +import { applyRoomBookmarkStompMessage } from "@/lib/stomp/bookmarks-dispatch"; + +const ME = 7; + +/** 이 계정(ME)이 폴더를 추가한 이벤트 */ +function categoryCreated(originTabId: string | null | undefined): string { + const event: RoomBookmarkChangedEvent = { + roomId: "room-1", + actorUserId: ME, + type: "CATEGORY_CREATED", + bookmarkId: 0, + categoryId: 10, + originTabId, + }; + return JSON.stringify(event); +} + +describe("applyRoomBookmarkStompMessage — echo of this tab", () => { + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.invalidateRoomBookmarkQueries.mockResolvedValue(undefined); + queryClient = new QueryClient(); + queryClient.setQueryData(sessionUserQueryKey, { id: ME }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("skips a change sent from this tab", async () => { + await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-a")); + + expect(mocks.invalidateRoomBookmarkQueries).not.toHaveBeenCalled(); + }); + + it("applies a change whose tab ID matches this tab but comes from another account", async () => { + queryClient.setQueryData(sessionUserQueryKey, { id: ME + 1 }); + + await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-a")); + + expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); + }); + + it("applies a change sent from another tab of the same account", async () => { + await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-b")); + + expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); + }); + + it.each([null, undefined])( + "applies a change of the same account without a tab ID (%s) — it may come from another tab", + async (originTabId) => { + await applyRoomBookmarkStompMessage(queryClient, categoryCreated(originTabId)); + + expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/src/lib/stomp/bookmarks-dispatch.tsx b/src/lib/stomp/bookmarks-dispatch.tsx index 5fe9f2f9..d3510493 100644 --- a/src/lib/stomp/bookmarks-dispatch.tsx +++ b/src/lib/stomp/bookmarks-dispatch.tsx @@ -3,7 +3,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { invalidateRoomBookmarkQueries } from "@/lib/bookmarks-invalidation"; -import { readSessionUserId } from "@/lib/session-user-cache"; +import { isOwnEcho } from "@/lib/stomp/own-echo"; import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp"; /** bookmarks 토픽 STOMP — React Query 무효화만 (토스트·선 fetch 없음) */ @@ -15,13 +15,8 @@ export async function applyRoomBookmarkStompMessage( const rid = String(event.roomId ?? "").trim(); if (!rid) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 mutation은 `onSuccess`에서 캐시 패치 */ - if (actorIsMe) { + /** 본인 탭의 변경은 그 탭의 mutation이 캐시를 맞춘다 */ + if (isOwnEcho(queryClient, event)) { return; } diff --git a/src/lib/stomp/own-echo.test.ts b/src/lib/stomp/own-echo.test.ts new file mode 100644 index 00000000..ec90a607 --- /dev/null +++ b/src/lib/stomp/own-echo.test.ts @@ -0,0 +1,48 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { sessionUserQueryKey } from "@/lib/query-keys"; + +const tab = vi.hoisted(() => ({ id: "tab-a" as string | null })); + +vi.mock("@/lib/tab-id", () => ({ getTabId: () => tab.id })); + +import { isOwnEcho } from "@/lib/stomp/own-echo"; + +const ME = 7; +const OTHER = 8; + +describe("isOwnEcho", () => { + const queryClient = new QueryClient(); + + afterEach(() => { + queryClient.clear(); + tab.id = "tab-a"; + }); + + it.each([ + { name: "sent from this tab by me", originTabId: "tab-a", actorUserId: ME, expected: true }, + { name: "sent from another tab of my account", originTabId: "tab-b", actorUserId: ME, expected: false }, + // 탭 ID는 방 멤버 모두에게 보여 흉내 낼 수 있다 — 다른 계정이면 반향이 아니다 + { name: "my tab ID forged by another account", originTabId: "tab-a", actorUserId: OTHER, expected: false }, + // 탭 ID가 없으면 어느 탭 것인지 몰라 놓치지 않도록 받는다 + { name: "no tab ID", originTabId: null, actorUserId: ME, expected: false }, + { name: "tab ID field missing", originTabId: undefined, actorUserId: ME, expected: false }, + { name: "empty tab ID", originTabId: "", actorUserId: ME, expected: false }, + ])("$name → $expected", ({ originTabId, actorUserId, expected }) => { + queryClient.setQueryData(sessionUserQueryKey, { id: ME }); + + expect(isOwnEcho(queryClient, { actorUserId, originTabId })).toBe(expected); + }); + + it("does not match a tab-less event in a tab that could not make an ID", () => { + tab.id = null; + queryClient.setQueryData(sessionUserQueryKey, { id: ME }); + + expect(isOwnEcho(queryClient, { actorUserId: ME, originTabId: null })).toBe(false); + }); + + it("receives this tab's change while the session user is not loaded yet", () => { + expect(isOwnEcho(queryClient, { actorUserId: ME, originTabId: "tab-a" })).toBe(false); + }); +}); diff --git a/src/lib/stomp/own-echo.ts b/src/lib/stomp/own-echo.ts new file mode 100644 index 00000000..0194f017 --- /dev/null +++ b/src/lib/stomp/own-echo.ts @@ -0,0 +1,21 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { readSessionUserId } from "@/lib/session-user-cache"; +import { getTabId } from "@/lib/tab-id"; + +type ChangeOrigin = { + actorUserId: number; + /** 변경 요청의 `X-Client-Tab-Id`. 헤더를 싣기 전 백엔드거나 탭 ID를 못 만든 탭의 변경이면 없다 */ + originTabId?: string | null; +}; + +/** + * 이 탭이 보낸 변경의 반향인지 판정한다 — 반향이면 그 탭의 mutation이 이미 캐시를 맞춘다. + * 탭 ID가 없는 이벤트는 어느 탭의 변경인지 알 수 없어, 놓치지 않도록 다른 탭의 변경으로 받는다. + * 탭 ID는 방 멤버 모두에게 보이고 헤더로 흉내 낼 수 있어, 서버가 인증으로 정한 `actorUserId`도 나여야 반향으로 본다. + */ +export function isOwnEcho(queryClient: QueryClient, event: ChangeOrigin): boolean { + if (!event.originTabId || event.originTabId !== getTabId()) return false; + const me = readSessionUserId(queryClient); + return typeof me === "number" && me === event.actorUserId; +} diff --git a/src/lib/stomp/schedule-events.ts b/src/lib/stomp/schedule-events.ts index b4cf8bbb..7b7e0b84 100644 --- a/src/lib/stomp/schedule-events.ts +++ b/src/lib/stomp/schedule-events.ts @@ -16,6 +16,8 @@ export type RoomScheduleChangedEvent = { itemId: number | null; affectedRouteItemIds: number[] | null; scheduleIds: number[] | null; + /** 변경 요청을 보낸 탭 ID — 같으면 이 탭의 반향(`isOwnEcho`) */ + originTabId?: string | null; }; const KNOWN_TYPES = new Set([ diff --git a/src/lib/stomp/schedules-dispatch.order.test.ts b/src/lib/stomp/schedules-dispatch.order.test.ts index 6656e6d8..9b7df890 100644 --- a/src/lib/stomp/schedules-dispatch.order.test.ts +++ b/src/lib/stomp/schedules-dispatch.order.test.ts @@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RoomScheduleItem } from "@/lib/api/rooms/schedule-items"; import type { PlanPlace } from "@/lib/plan/types"; -import { roomSchedulesQueryKey, scheduleItemsQueryKey } from "@/lib/query-keys"; +import { + roomSchedulesQueryKey, + scheduleItemsQueryKey, + sessionUserQueryKey, +} from "@/lib/query-keys"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; const fixtures = vi.hoisted(() => ({ @@ -36,27 +40,13 @@ vi.mock("@/lib/plan/schedule-bulk-hydration", async (importOriginal) => ({ .map((item) => fixtures.place(item.itemId)), resolveScheduleItemsFromCacheOrHydrate: vi.fn(), })); -vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ - bumpPlanMapRouteSegments: vi.fn(), - invalidateScheduleItemRouteForSources: vi.fn(async () => undefined), - invalidateScheduleItemRouteForWholeSchedule: vi.fn(async () => undefined), - invalidateScheduleItemRoutesAfterDelete: vi.fn(async () => undefined), - invalidateScheduleItemRoutesAfterItemCreated: vi.fn(async () => undefined), - invalidateScheduleItemRoutesAfterReorder: vi.fn(async () => undefined), - readOrderedItemIdsFromScheduleItemsCache: vi.fn(() => null), - removeRouteQueriesForDeletedItemSource: vi.fn(), -})); vi.mock("@/lib/rooms", () => ({ syncRoomDetailFromServer: vi.fn(), })); vi.mock("@/lib/query-client", () => ({ getQueryClient: () => null, })); -vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ - usePlanMapDirectionsEpochStore: { - getState: () => ({ bumpForDirections: vi.fn(), bumpSegments: vi.fn() }), - }, -})); +vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); import { removeScheduleItemFromPlanPlacesCache, @@ -251,6 +241,23 @@ describe("schedule day list refetch order", () => { expect(fixtures.getScheduleItems).toHaveBeenCalledOnce(); }); + it("shows a reorder made in another tab of the same account", async () => { + // remoteEvent의 actorUserId(2)가 이 계정 — 예전에는 계정이 같아 버렸다 + client.setQueryData(sessionUserQueryKey, { id: 2 }); + client.setQueryData(DAY_KEY, [100, 101, 102].map(fixtures.place)); + watchDayList(); + fixtures.getScheduleItems.mockResolvedValue(items(102, 100, 101)); + + await dispatchRoomScheduleEvent(client, { + ...remoteEvent("SCHEDULE_ITEMS_REORDERED", 102), + originTabId: "tab-b", + }); + await settle(); + + expect(fixtures.getScheduleItems).toHaveBeenCalledOnce(); + expect(dayItemIds()).toEqual([102, 100, 101]); + }); + it("fetches from the server when the day list is invalidated", async () => { client.setQueryData(DAY_KEY, [100, 102].map(fixtures.place)); watchDayList(); diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index cbd60a4f..89bf39d4 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -1,20 +1,14 @@ import { QueryClient } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { sessionUserQueryKey } from "@/lib/query-keys"; +import { scheduleItemRouteQueryKey, sessionUserQueryKey } from "@/lib/query-keys"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; const mocks = vi.hoisted(() => ({ - collectSegmentSourcesForCreate: vi.fn(), - collectSegmentSourcesForDelete: vi.fn(), - collectSegmentSourcesForReorder: vi.fn(), invalidateSchedulePlanPlaces: vi.fn(), - invalidateScheduleItemRouteForSources: vi.fn(), - invalidateScheduleItemRouteForWholeSchedule: vi.fn(), - readOrderedItemIdsFromScheduleItemsCache: vi.fn(), - removeRouteQueriesForDeletedItemSource: vi.fn(), syncAfterCrossScheduleItemMove: vi.fn(), + syncRoomDetailFromServer: vi.fn(), })); vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ @@ -22,32 +16,12 @@ vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ syncAfterCrossScheduleItemMove: mocks.syncAfterCrossScheduleItemMove, })); -vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ - collectSegmentSourcesForCreate: mocks.collectSegmentSourcesForCreate, - collectSegmentSourcesForDelete: mocks.collectSegmentSourcesForDelete, - collectSegmentSourcesForReorder: mocks.collectSegmentSourcesForReorder, - invalidateScheduleItemRouteForSources: - mocks.invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule: - mocks.invalidateScheduleItemRouteForWholeSchedule, - readOrderedItemIdsFromScheduleItemsCache: - mocks.readOrderedItemIdsFromScheduleItemsCache, - removeRouteQueriesForDeletedItemSource: - mocks.removeRouteQueriesForDeletedItemSource, -})); - vi.mock("@/lib/rooms", () => ({ - syncRoomDetailFromServer: vi.fn(), + syncRoomDetailFromServer: mocks.syncRoomDetailFromServer, })); -vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ - usePlanMapDirectionsEpochStore: { - getState: () => ({ - bumpForDirections: vi.fn(), - bumpSegments: vi.fn(), - }), - }, -})); +vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); +const MY_TAB_ID = "tab-a"; import { dispatchRoomScheduleEvent } from "@/lib/stomp/schedules-dispatch"; @@ -68,15 +42,14 @@ function itemEvent( describe("dispatchRoomScheduleEvent", () => { let queryClient: QueryClient; + // 경로는 도착 항목까지 key에 있어 일정이 바뀌어도 무효화하지 않는다 — 순서가 바뀐 구간은 새 key로 받는다 + const routeKey = scheduleItemRouteQueryKey("room-1", 10, 20, 21, "DRIVING"); beforeEach(() => { queryClient = new QueryClient(); + queryClient.setQueryData(routeKey, { travelMode: "DRIVING", distanceMeters: 100, durationSeconds: 60 }); vi.clearAllMocks(); mocks.invalidateSchedulePlanPlaces.mockResolvedValue(undefined); - mocks.invalidateScheduleItemRouteForSources.mockResolvedValue(undefined); - mocks.invalidateScheduleItemRouteForWholeSchedule.mockResolvedValue( - undefined, - ); mocks.syncAfterCrossScheduleItemMove.mockResolvedValue(undefined); }); @@ -85,85 +58,28 @@ describe("dispatchRoomScheduleEvent", () => { queryClient.clear(); }); - it.each([false, true])("refetches ID-only time updates, including own-user tabs (%s)", async (ownUser) => { + it("refetches consecutive ID-only time updates once", async () => { vi.useFakeTimers(); - if (ownUser) queryClient.setQueryData(sessionUserQueryKey, { id: 99 }); const event = itemEvent("SCHEDULE_ITEM_UPDATED", []); await dispatchRoomScheduleEvent(queryClient, event); await dispatchRoomScheduleEvent(queryClient, event); await vi.runAllTimersAsync(); - // 본인 반향만 모였으면 본인 저장이 끝나며 다시 받는 조회에 합류한다 - expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { - joinInFlight: ownUser, - }); - expect(mocks.invalidateScheduleItemRouteForSources).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForWholeSchedule).not.toHaveBeenCalled(); - }); - - it("uses affectedRouteItemIds instead of calculating routes for a created item", async () => { - mocks.readOrderedItemIdsFromScheduleItemsCache.mockReturnValue([10, 20]); - mocks.collectSegmentSourcesForCreate.mockReturnValue({ - sources: [10, 20], - useFallback: false, - }); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_CREATED", [777]), - ); - - expect(mocks.collectSegmentSourcesForCreate).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [777], - ); - }); - - it("uses affectedRouteItemIds instead of calculating routes for a deleted item", async () => { - mocks.collectSegmentSourcesForDelete.mockReturnValue({ - sources: [10], - useFallback: false, - }); - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_DELETED", [888]), - ); - - expect(mocks.collectSegmentSourcesForDelete).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [888], - ); + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); - it("uses affectedRouteItemIds instead of calculating routes after reorder", async () => { - vi.useFakeTimers(); - mocks.collectSegmentSourcesForReorder.mockReturnValue({ - sources: [10, 20], - useFallback: false, - }); + it.each(["SCHEDULE_ITEM_CREATED", "SCHEDULE_ITEM_DELETED", "SCHEDULE_ITEMS_REORDERED"] as const)( + "refetches the day list without invalidating route caches for %s", + async (type) => { + await dispatchRoomScheduleEvent(queryClient, itemEvent(type, [20])); - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEMS_REORDERED", [999]), - ); - await vi.runAllTimersAsync(); + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); + }, + ); - expect(mocks.collectSegmentSourcesForReorder).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [999], - ); - }); - - it("passes affectedRouteItemIds through a cross-schedule move", async () => { + it("refetches both days after a cross-schedule move", async () => { const event = itemEvent("SCHEDULE_ITEM_MOVED", [11, 21, 20]); event.scheduleId = 20; event.scheduleIds = [10, 20]; @@ -175,73 +91,21 @@ describe("dispatchRoomScheduleEvent", () => { "room-1", 10, 20, - 20, - [11, 21, 20], - ); - }); - - it("refreshes travel mode before invalidating affectedRouteItemIds", async () => { - vi.useFakeTimers(); - const order: string[] = []; - mocks.invalidateSchedulePlanPlaces.mockImplementation(async () => { - order.push("refetch"); - }); - mocks.invalidateScheduleItemRouteForSources.mockImplementation(async () => { - order.push("invalidate"); - }); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", [20]), - ); - await vi.runAllTimersAsync(); - - expect(order).toEqual(["refetch", "invalidate"]); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [20], ); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); - it("preserves a newer route bucket when an earlier travel-mode refresh fails", async () => { + it("refetches the day list after another member changes a travel mode, leaving route caches as they are", async () => { vi.useFakeTimers(); - let rejectFirst!: (reason?: unknown) => void; - let resolveSecond!: () => void; - const first = new Promise((_, reject) => { - rejectFirst = reject; - }); - const second = new Promise((resolve) => { - resolveSecond = resolve; - }); - mocks.invalidateSchedulePlanPlaces - .mockReturnValueOnce(first) - .mockReturnValueOnce(second); await dispatchRoomScheduleEvent( queryClient, itemEvent("SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", [20]), ); - await vi.advanceTimersByTimeAsync(90); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", [30]), - ); - await vi.advanceTimersByTimeAsync(90); - - rejectFirst(new Error("first refresh failed")); - resolveSecond(); await vi.runAllTimersAsync(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledTimes(1); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [30], - ); + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); }); @@ -255,3 +119,86 @@ describe("expense synchronization on existing schedule events",()=>{ client.clear(); }); }); + +describe("echo of this tab vs. other tabs of the same account", () => { + const SKIPPABLE_TYPES = [ + "ROOM_SCHEDULES_RESYNCED", + "SCHEDULE_ITEM_MOVED", + "SCHEDULE_ITEM_DELETED", + "SCHEDULE_ITEM_CREATED", + "SCHEDULE_ITEMS_REORDERED", + "SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", + ] as const; + let queryClient: QueryClient; + + /** itemEvent의 actorUserId(99)가 이 계정이다 */ + function ownAccountEvent( + type: RoomScheduleChangedEvent["type"], + originTabId: string | null, + ): RoomScheduleChangedEvent { + return { ...itemEvent(type, [20]), scheduleIds: [10, 11], originTabId }; + } + + /** 일차 목록·일정 전체·방 상세 중 어느 것이든 다시 받은 횟수 */ + const refetchCount = () => + mocks.invalidateSchedulePlanPlaces.mock.calls.length + + mocks.syncAfterCrossScheduleItemMove.mock.calls.length + + mocks.syncRoomDetailFromServer.mock.calls.length; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.invalidateSchedulePlanPlaces.mockResolvedValue(undefined); + mocks.syncAfterCrossScheduleItemMove.mockResolvedValue(undefined); + queryClient = new QueryClient(); + queryClient.setQueryData(sessionUserQueryKey, { id: 99 }); + }); + + afterEach(() => { + vi.useRealTimers(); + queryClient.clear(); + }); + + it.each(SKIPPABLE_TYPES)("skips %s sent from this tab", async (type) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, MY_TAB_ID)); + await vi.runAllTimersAsync(); + + expect(refetchCount()).toBe(0); + }); + + it.each(SKIPPABLE_TYPES)("applies %s sent from another tab of the same account", async (type) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, "tab-b")); + await vi.runAllTimersAsync(); + + expect(refetchCount()).toBe(1); + }); + + it.each(SKIPPABLE_TYPES)("applies %s of the same account without a tab ID — it may come from another tab", async (type) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, null)); + await vi.runAllTimersAsync(); + + expect(refetchCount()).toBe(1); + }); + + it("applies a change whose tab ID matches this tab but comes from another account", async () => { + queryClient.setQueryData(sessionUserQueryKey, { id: 100 }); + + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_DELETED", MY_TAB_ID)); + + expect(refetchCount()).toBe(1); + }); + + it("skips a time update sent from this tab — its own settled refetch already has it", async () => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", MY_TAB_ID)); + await vi.runAllTimersAsync(); + + expect(mocks.invalidateSchedulePlanPlaces).not.toHaveBeenCalled(); + }); + + it.each(["tab-b", null])("refetches a time update of the same account from another tab (%s)", async (originTabId) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", originTabId)); + await vi.runAllTimersAsync(); + + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); + }); +}); diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index 6f1bf699..3a10e7ea 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -5,178 +5,33 @@ import { invalidateSchedulePlanPlaces, syncAfterCrossScheduleItemMove, } from "@/lib/plan/scheduleItemPlaces"; -import { - invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule, - removeRouteQueriesForDeletedItemSource, -} from "@/lib/plan/scheduleStompRouteScope"; import { roomSchedulesQueryKey } from "@/lib/query-keys"; import { syncRoomDetailFromServer } from "@/lib/rooms"; +import { isOwnEcho } from "@/lib/stomp/own-echo"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; -import { readSessionUserId } from "@/lib/session-user-cache"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; - -function bumpMapForRouteSources( - roomId: string, - scheduleId: number, - sourceItemIds: number[], -): void { - if (sourceItemIds.length === 0) return; - usePlanMapDirectionsEpochStore.getState().bumpSegments( - roomId, - sourceItemIds.map((segmentSourceItemId) => ({ - scheduleId, - segmentSourceItemId, - })), - ); -} - -type DebouncedRouteInvBucket = - | { scope: "whole" } - | { scope: "sources"; ids: Set }; - -/** 리오더 시 같은 구간 무효화를 짧게 묶어 `/route` 이중 패치를 줄임 */ -const scheduleRouteInvalidateDebounceTimers = new Map< - string, - ReturnType ->(); -const scheduleRouteInvalidatePending = new Map(); -const DEBOUNCE_SCHEDULE_ROUTE_INVALIDATE_MS = 100; - -function mergeRouteInvalidateBucket( - prev: DebouncedRouteInvBucket | undefined, - sourcesToAdd: number[], - forceWholeSchedule: boolean, -): DebouncedRouteInvBucket { - if (forceWholeSchedule || prev?.scope === "whole") { - return { scope: "whole" }; - } - const ids = - prev?.scope === "sources" ? prev.ids : new Set(); - for (const id of sourcesToAdd) { - ids.add(id); - } - return { scope: "sources", ids }; -} - -function enqueueDebouncedInvalidateScheduleRoutes( - queryClient: QueryClient, - rid: string, - sid: number, - sourcesForInvalidation: number[], - forceWholeSchedule: boolean, -): void { - const mapKey = `${rid}:${sid}`; - scheduleRouteInvalidatePending.set( - mapKey, - mergeRouteInvalidateBucket( - scheduleRouteInvalidatePending.get(mapKey), - sourcesForInvalidation, - forceWholeSchedule, - ), - ); - const existing = scheduleRouteInvalidateDebounceTimers.get(mapKey); - if (existing !== undefined) clearTimeout(existing); - - scheduleRouteInvalidateDebounceTimers.set( - mapKey, - setTimeout(() => { - scheduleRouteInvalidateDebounceTimers.delete(mapKey); - const epochStore = usePlanMapDirectionsEpochStore.getState(); - const bucket = scheduleRouteInvalidatePending.get(mapKey); - scheduleRouteInvalidatePending.delete(mapKey); - if (!bucket) return; - void (async () => { - if (bucket.scope === "whole") { - await invalidateScheduleItemRouteForWholeSchedule( - queryClient, - rid, - sid, - ); - epochStore.bumpForDirections(rid); - } else if (bucket.ids.size > 0) { - const sources = [...bucket.ids]; - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - sid, - sources, - ); - bumpMapForRouteSources(rid, sid, sources); - } - })(); - }, DEBOUNCE_SCHEDULE_ROUTE_INVALIDATE_MS), - ); -} /** 연속 일정 항목 동기화(GET items)를 한 번으로 묶음 — `SCHEDULE_ITEM_UPDATED` 전용 */ const syncSchedulePlacesFromItemsDebouncers = new Map< string, ReturnType >(); -const syncSchedulePlacesRouteInvalidationPending = new Map< - string, - DebouncedRouteInvBucket ->(); -/** 모으는 동안 다른 사람의 변경이 하나라도 들어온 일차 */ -const syncSchedulePlacesRemoteChangePending = new Set(); const DEBOUNCE_ITEMS_GET_MS = 90; function enqueueDebouncedHydratePlacesFromScheduleItemsApi( queryClient: QueryClient, rid: string, sid: number, - ownEcho: boolean, - affectedRouteItemIds?: number[] | null, ): void { const mapKey = `${rid}:${sid}`; - if (!ownEcho) syncSchedulePlacesRemoteChangePending.add(mapKey); - if (affectedRouteItemIds !== undefined) { - syncSchedulePlacesRouteInvalidationPending.set( - mapKey, - mergeRouteInvalidateBucket( - syncSchedulePlacesRouteInvalidationPending.get(mapKey), - affectedRouteItemIds ?? [], - affectedRouteItemIds === null, - ), - ); - } const pending = syncSchedulePlacesFromItemsDebouncers.get(mapKey); if (pending !== undefined) clearTimeout(pending); syncSchedulePlacesFromItemsDebouncers.set( mapKey, setTimeout(() => { syncSchedulePlacesFromItemsDebouncers.delete(mapKey); - const routeBucket = - syncSchedulePlacesRouteInvalidationPending.get(mapKey); - syncSchedulePlacesRouteInvalidationPending.delete(mapKey); - // 모은 이벤트가 모두 본인 반향이면, 본인 저장이 끝나며 보낸 조회가 이미 그 변경을 담고 있어 합류한다 - const joinInFlight = !syncSchedulePlacesRemoteChangePending.has(mapKey); - syncSchedulePlacesRemoteChangePending.delete(mapKey); void (async () => { try { - await invalidateSchedulePlanPlaces(queryClient, rid, sid, { - joinInFlight, - }); - if (routeBucket?.scope === "whole") { - await invalidateScheduleItemRouteForWholeSchedule( - queryClient, - rid, - sid, - ); - usePlanMapDirectionsEpochStore - .getState() - .bumpForDirections(rid); - } else if (routeBucket && routeBucket.ids.size > 0) { - const sources = [...routeBucket.ids]; - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - sid, - sources, - ); - bumpMapForRouteSources(rid, sid, sources); - } + await invalidateSchedulePlanPlaces(queryClient, rid, sid); } catch { // } @@ -187,12 +42,13 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( /** * Plan 화면 등이 쓰는 캐시만, 스키마의 `type`별로 갱신합니다. - * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 (원격 탭; 액터는 mutation `onSuccess`) + * — 이 탭이 보낸 변경의 반향(`isOwnEcho`)은 건너뛴다. 그 탭의 mutation이 캐시를 맞춘다. + * 같은 계정의 다른 탭과 탭 ID가 없는 변경은 다른 사람의 변경처럼 받는다 + * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 * — `schedule-items`: 그 일차 장소 목록을 무효화해 다시 받는다(보는 화면이 없으면 그 화면에 들어갈 때) - * — `schedule-item-route`: `itemId`·인접 구간의 `segmentSourceItemId`만 무효화(폴백 시 일정 전체) - * — `SCHEDULE_ITEM_UPDATED`: 일정 목록만 동기화할 때 사용(본인 이벤트 포함); 경로(`schedule-item-route`)는 무효화하지 않음 - * — `SCHEDULE_ITEM_TRAVEL_MODE_UPDATED`: 공유 이동수단 변경 — 일정 목록만 동기화(경로 캐시는 수단별 키로 유지) - * — 맵 polyline: 구간별 에폭(`bumpSegments`); 폴백 시 방 단위(`bumpForDirections`) + * — `schedule-item-route`: 무효화하지 않는다 — key에 도착 항목·이동수단이 있어, 순서·수단이 바뀐 구간은 + * 새 목록을 그릴 때 새 key로 받고 되돌리면 받아 둔 값을 쓴다 + * — `SCHEDULE_ITEM_UPDATED`·`SCHEDULE_ITEM_TRAVEL_MODE_UPDATED`: 연속 이벤트를 모아 그 일차를 한 번만 다시 받는다 */ export async function dispatchRoomScheduleEvent( queryClient: QueryClient, @@ -207,20 +63,10 @@ export async function dispatchRoomScheduleEvent( ) { await invalidateExpenses(queryClient, rid); } - const epochStore = usePlanMapDirectionsEpochStore.getState(); + if (isOwnEcho(queryClient, event)) return; switch (event.type) { case "ROOM_SCHEDULES_RESYNCED": { - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 mutation은 `onSuccess`에서 schedules·room-detail 캐시 갱신 */ - if (actorIsMe) { - return; - } - // 일정 전체 재조회는 room-schedules 조회 하나로 한다 — 진행 중인 옛 조회가 취소돼 새 일정을 덮지 않는다 await queryClient.invalidateQueries({ queryKey: roomSchedulesQueryKey(rid) }); await syncRoomDetailFromServer(queryClient, rid); @@ -233,24 +79,12 @@ export async function dispatchRoomScheduleEvent( const sourceScheduleId = scheduleIds[0]!; const targetScheduleId = scheduleIds[1]!; - const itemId = event.itemId; - - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - if (actorIsMe) { - return; - } await syncAfterCrossScheduleItemMove( queryClient, rid, sourceScheduleId, targetScheduleId, - itemId ?? undefined, - event.affectedRouteItemIds, ); return; } @@ -260,28 +94,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 mutation은 `applyScheduleItemDeletedOnClient`에서 캐시·route 처리 */ - if (actorIsMe) { - return; - } - - removeRouteQueriesForDeletedItemSource(queryClient, rid, sid, itemId); - await invalidateSchedulePlanPlaces(queryClient, rid, sid); - - const sources = event.affectedRouteItemIds; - if (sources === null) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - epochStore.bumpForDirections(rid); - } else { - await invalidateScheduleItemRouteForSources(queryClient, rid, sid, sources); - bumpMapForRouteSources(rid, sid, sources); - } return; } @@ -290,26 +103,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 mutation은 append/reorder 직후 구간 단위 무효화 — STOMP 선도착 시 일정 전체 `/route` 폭주 방지 */ - if (actorIsMe) { - return; - } - await invalidateSchedulePlanPlaces(queryClient, rid, sid); - - const sources = event.affectedRouteItemIds; - if (sources === null) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - epochStore.bumpForDirections(rid); - } else { - await invalidateScheduleItemRouteForSources(queryClient, rid, sid, sources); - bumpMapForRouteSources(rid, sid, sources); - } return; } @@ -318,25 +112,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 mutation은 `syncPlanPlacesAfterReorderSuccess` 직후 구간 무효화 */ - if (actorIsMe) { - return; - } - await invalidateSchedulePlanPlaces(queryClient, rid, sid); - const sources = event.affectedRouteItemIds; - enqueueDebouncedInvalidateScheduleRoutes( - queryClient, - rid, - sid, - sources ?? [], - sources === null, - ); return; } @@ -345,18 +121,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - event.actorUserId === me; - // 같은 사용자의 다른 탭도 이 이벤트를 받으므로 HTTP 응답과 무관하게 목록을 동기화합니다. - enqueueDebouncedHydratePlacesFromScheduleItemsApi( - queryClient, - rid, - sid, - actorIsMe, - ); + enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid); return; } @@ -365,24 +130,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - event.actorUserId === me; - /** 본인 PATCH는 mutation `onSuccess`에서 `schedule-items` 캐시 갱신 */ - if (actorIsMe) { - return; - } - - /** 항목 재조회 후 서버가 지정한 출발 항목의 경로만 갱신합니다. */ - enqueueDebouncedHydratePlacesFromScheduleItemsApi( - queryClient, - rid, - sid, - false, - event.affectedRouteItemIds, - ); + enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid); return; } } diff --git a/src/lib/tab-id.test.ts b/src/lib/tab-id.test.ts new file mode 100644 index 00000000..3724141c --- /dev/null +++ b/src/lib/tab-id.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** 탭 ID는 모듈 변수에 한 번 만들어 두므로 경우마다 모듈을 새로 읽는다 */ +async function loadGetTabId() { + vi.resetModules(); + return (await import("@/lib/tab-id")).getTabId; +} + +describe("getTabId", () => { + beforeEach(() => { + vi.stubGlobal("window", {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns null during server render", async () => { + vi.unstubAllGlobals(); + const getTabId = await loadGetTabId(); + + expect(getTabId()).toBeNull(); + }); + + it("creates the ID once with randomUUID and keeps it for the tab", async () => { + const randomUUID = vi.fn(() => "3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"); + vi.stubGlobal("crypto", { randomUUID, getRandomValues: vi.fn() }); + const getTabId = await loadGetTabId(); + + expect(getTabId()).toBe("3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"); + expect(getTabId()).toBe("3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"); + expect(randomUUID).toHaveBeenCalledOnce(); + }); + + it("builds a v4 UUID from getRandomValues when randomUUID is missing (non-secure context)", async () => { + vi.stubGlobal("crypto", { + getRandomValues: (bytes: Uint8Array) => bytes.fill(0xff), + }); + const getTabId = await loadGetTabId(); + + const id = getTabId(); + expect(id).toMatch(UUID_V4); + expect(id).toBe("ffffffff-ffff-4fff-bfff-ffffffffffff"); + }); + + it("returns null when the browser cannot make random values", async () => { + vi.stubGlobal("crypto", {}); + const getTabId = await loadGetTabId(); + + expect(getTabId()).toBeNull(); + }); +}); diff --git a/src/lib/tab-id.ts b/src/lib/tab-id.ts new file mode 100644 index 00000000..95050886 --- /dev/null +++ b/src/lib/tab-id.ts @@ -0,0 +1,29 @@ +/** + * 이 탭이 보낸 변경 요청과 그 STOMP 반향(`originTabId`)을 잇는 ID. + * + * 모듈 변수에만 둔다. sessionStorage는 탭을 복제하면 값이 복사돼 두 탭이 같은 ID가 되고, + * localStorage는 모든 탭이 같은 값을 쓴다. 새로고침하면 새 ID가 된다. + */ +let tabId: string | null | undefined; + +/** 서버 렌더에서는 null. UUID를 만들 수 없는 브라우저도 null이고, 그때는 헤더를 싣지 않는다 */ +export function getTabId(): string | null { + if (tabId !== undefined) return tabId; + if (typeof window === "undefined") return null; + tabId = createUuidV4(); + return tabId; +} + +function createUuidV4(): string | null { + if (typeof crypto === "undefined") return null; + // randomUUID는 보안 컨텍스트(https·localhost)에서만 있다 — `http://`로 연 화면은 getRandomValues로 만든다 + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + if (typeof crypto.getRandomValues !== "function") return null; + + const hex = Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + // RFC 9562 v4: 13번째 자리는 버전 4, 17번째 자리는 변형 8~b + const variant = ((Number.parseInt(hex.charAt(16), 16) & 0x3) | 0x8).toString(16); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} diff --git a/src/stores/plan-map-directions-epoch-store.ts b/src/stores/plan-map-directions-epoch-store.ts deleted file mode 100644 index f38add8f..00000000 --- a/src/stores/plan-map-directions-epoch-store.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { create } from "zustand"; - -type PlanMapSegmentEpochEntry = { - scheduleId: number; - segmentSourceItemId: number; -}; - -/** `epochBySegmentKey`: `{roomId}:{scheduleId}:{segmentSourceItemId}` */ -export function planMapSegmentEpochStoreKey( - roomId: string, - scheduleId: number, - segmentSourceItemId: number, -): string { - return `${String(roomId ?? "").trim()}:${scheduleId}:${segmentSourceItemId}`; -} - -/** STOMP 일정 브로드캐스트 중 지도 경로(polyline) 갱신 — 방 전체 에폭 + 구간별 에폭. */ -type EpochState = { - epochByRoomId: Record; - epochBySegmentKey: Record; - bumpForDirections: (roomId: string) => void; - bumpSegments: (roomId: string, entries: PlanMapSegmentEpochEntry[]) => void; -}; - -export const usePlanMapDirectionsEpochStore = create((set) => ({ - epochByRoomId: {}, - epochBySegmentKey: {}, - bumpForDirections: (roomId) => { - const rid = String(roomId ?? "").trim(); - if (!rid.length) return; - set((s) => ({ - epochByRoomId: { - ...s.epochByRoomId, - [rid]: (s.epochByRoomId[rid] ?? 0) + 1, - }, - })); - }, - bumpSegments: (roomId, entries) => { - const rid = String(roomId ?? "").trim(); - if (!rid.length || entries.length === 0) return; - set((s) => { - const next = { ...s.epochBySegmentKey }; - for (const e of entries) { - const k = planMapSegmentEpochStoreKey(rid, e.scheduleId, e.segmentSourceItemId); - next[k] = (next[k] ?? 0) + 1; - } - return { epochBySegmentKey: next }; - }); - }, -})); diff --git a/src/types/roomBookmarkStomp.ts b/src/types/roomBookmarkStomp.ts index 721e17d3..25ec194b 100644 --- a/src/types/roomBookmarkStomp.ts +++ b/src/types/roomBookmarkStomp.ts @@ -12,4 +12,6 @@ export type RoomBookmarkChangedEvent = { categoryId: number; roomId: string; type: RoomBookmarkChangedType; + /** 변경 요청을 보낸 탭 ID — 같으면 이 탭의 반향(`isOwnEcho`) */ + originTabId?: string | null; };