From a6066f09e6d4524b9c9474d7d6cfc4ab5c8901a0 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:01:04 +0900 Subject: [PATCH 01/27] =?UTF-8?q?refactor(bookmarks):=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=EB=B3=84=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EC=A0=84=EC=B2=B4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=EC=84=9C=20=EA=B1=B0=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전체 목록 queryFn이 카테고리별 key에 써 넣던 시딩을 없애고, useRoomBookmarks는 같은 전체 목록 query의 select로 거른다 - 사본이 1벌이 되어 옛 전체 스냅샷이 최신 카테고리 목록을 덮거나 0개가 된 카테고리에 옛 목록이 남지 않는다 - 시딩 때문에만 마운트하던 BookmarkFoldersView·BookmarkFolderDetailView·AddFromBookmarkModal의 useAllRoomBookmarks 줄 정리 - 쓰지 않게 된 카테고리별 key·GET(getRoomBookmarks)·bookmark-bulk-cache 삭제, 재구독 복구는 전체 목록 key만 무효화 - 추가 응답 반영은 전체 목록에만 하고, 목록을 받은 적이 없으면 일부만 든 목록을 만들지 않는다 Co-Authored-By: Claude Opus 5.5 --- .../BookmarkFolderDetailView.loading.test.tsx | 1 - .../_components/BookmarkFolderDetailView.tsx | 4 +- .../_components/BookmarkFoldersView.tsx | 2 - .../itinerary/AddFromBookmarkModal.tsx | 3 -- .../layout/planning-chrome.test.tsx | 1 - src/hooks/useRooms.ts | 48 ++++++++----------- src/lib/api/rooms/bookmarks.ts | 12 ----- src/lib/api/rooms/index.ts | 1 - src/lib/bookmarks-invalidation.ts | 23 --------- src/lib/bookmarks/bookmark-bulk-cache.ts | 46 ------------------ src/lib/bookmarks/bookmark-cache-patch.ts | 25 ++-------- src/lib/query-keys.ts | 14 ------ src/lib/stomp/room-resync-dispatch.test.ts | 4 ++ src/lib/stomp/room-resync-dispatch.ts | 2 - 14 files changed, 31 insertions(+), 155 deletions(-) delete mode 100644 src/lib/bookmarks/bookmark-bulk-cache.ts diff --git a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx index fc49ceee..179a43f9 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx @@ -9,7 +9,6 @@ const placeQueries = vi.hoisted(() => ({ current: [] as unknown[] })); vi.mock("@/hooks/useOpenPlaceOnMap", () => ({ useOpenPlaceOnMap: () => vi.fn() })); vi.mock("@/stores/session-store", () => ({ useSessionStore: () => "room" })); vi.mock("@/hooks/useRooms", () => ({ - useAllRoomBookmarks: () => undefined, useRoomBookmarks: () => ({ data: [{ bookmarkId: 1, googlePlaceId: "place" }], isPending: false }), })); vi.mock("@/lib/places/place-queries", () => ({ placePreviewQueryOptions: () => ({ queryKey: ["place"] }) })); diff --git a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx index d1076e01..e932be63 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx @@ -5,7 +5,7 @@ import { useQueries } from "@tanstack/react-query"; import { placePreviewQueryOptions } from "@/lib/places/place-queries"; import { useOpenPlaceOnMap } from "@/hooks/useOpenPlaceOnMap"; -import { useAllRoomBookmarks, useRoomBookmarks } from "@/hooks/useRooms"; +import { useRoomBookmarks } from "@/hooks/useRooms"; import { useSessionStore } from "@/stores/session-store"; import type { RoomBookmark } from "@/lib/api/rooms"; import type { BookmarkFolder, BookmarkedPlace } from "@/types/bookmark"; @@ -31,8 +31,6 @@ export function BookmarkFolderDetailView({ folder }: { folder: BookmarkFolder }) const categoryId = Number.parseInt(folder.id, 10); const categoryIdOk = Number.isFinite(categoryId) ? categoryId : null; - useAllRoomBookmarks(roomId); - const { data: bookmarkRowsRaw, isPending: bookmarksLoading, diff --git a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx index 249b6a51..6735b9c7 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx @@ -25,7 +25,6 @@ import { import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { pickUniqueUntitledBookmarkCategoryName } from "@/lib/bookmark-untitled-category-name"; import { - useAllRoomBookmarks, useBookmarkCategories, useCreateBookmarkCategory, useDeleteBookmarkCategory, @@ -43,7 +42,6 @@ import { FolderRibbonIcon } from "./FolderRibbonIcon"; export function BookmarkFoldersView() { const roomId = useSessionStore((s) => s.currentRoomId); const { folders } = useBookmarkFolders(); - useAllRoomBookmarks(roomId); const { isPending, isLoadingError, error, refetch } = useBookmarkCategories(roomId); const { mutate: createCategory, isPending: isCreating } = diff --git a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx index 15d4b71f..d5f128d1 100644 --- a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx +++ b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx @@ -20,7 +20,6 @@ import { toast } from "sonner"; import { FolderRibbonIcon } from "@/app/(main)/bookmark/_components/FolderRibbonIcon"; import { BookmarkPlacePreviewCard } from "@/app/(main)/bookmark/_components/BookmarkPlacePreviewCard"; import { - useAllRoomBookmarks, useBookmarkCategories, useCreateScheduleItem, useRoomBookmarks, @@ -68,8 +67,6 @@ export function AddFromBookmarkModal({ refetch: refetchCategories, } = useBookmarkCategories(roomId); - useAllRoomBookmarks(roomId); - useEffect(() => { if (!categories?.length) return; setSelectedCategoryId((prev) => { diff --git a/src/components/layout/planning-chrome.test.tsx b/src/components/layout/planning-chrome.test.tsx index 05dc1e9c..5b68cc72 100644 --- a/src/components/layout/planning-chrome.test.tsx +++ b/src/components/layout/planning-chrome.test.tsx @@ -11,7 +11,6 @@ vi.mock("@/hooks/useCurrentRoomMembership", () => ({ useCurrentRoomMembership: ( vi.mock("@/hooks/useRoomDetail", () => ({ useRoomDetail: () => ({ data: undefined }) })); vi.mock("@/hooks/useRooms", () => ({ useRoomsList: () => ({ data: { rooms: [] }, isPending: false }), - useAllRoomBookmarks: vi.fn(), useBookmarkCategories: () => ({ isPending: false, isError: false }), useCreateBookmarkCategory: () => ({}), useDeleteBookmarkCategory: () => ({}), diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index c72bd5cf..81c1e5f4 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -4,6 +4,7 @@ import { beginExpenseRoomAdmission } from "@/lib/expenses/expense-recovery"; import { joinStatusForWaitingUi } from "@/lib/join-room-workflow"; import { invalidateExpenses, expenseKeys } from "@/lib/expenses/expense-queries"; import { + queryOptions, useMutation, useQuery, useQueryClient, @@ -32,7 +33,7 @@ import { getBookmarkCategories, getJoinRequests, getJoinStatus, - getRoomBookmarks, + getAllRoomBookmarks, getRoomMembers, patchRoomBookmarkCategory, getRooms, @@ -66,7 +67,6 @@ import { syncPlanPlacesAfterReorderSuccess, } from "@/lib/plan/scheduleItemPlaces"; import { planPlacesNeedPreviewEnrich } from "@/lib/plan/schedule-bulk-hydration"; -import { fetchAndSeedAllRoomBookmarks } from "@/lib/bookmarks/bookmark-bulk-cache"; import { appendBookmarkCategoryToCache, appendRoomBookmarksToCache, @@ -75,7 +75,6 @@ import { bookmarkCategoriesQueryKey, joinRequestsQueryKey, roomAllBookmarksQueryKey, - roomBookmarksQueryKey, ROOMS_QUERY_KEY, roomDetailQueryKey, roomMembersQueryKey, @@ -798,20 +797,27 @@ export function useDeleteBookmarkCategory() { }); } +/** + * 방 전체 북마크 조회 옵션. 카테고리별 목록도 이 캐시 하나에서 거른다 — 서버의 카테고리별 응답과 모양이 같고 + * 카테고리 안 순서(최근 추가 순)도 같아서, 폴더를 바꿀 때 GET·스켈레톤 없이 같은 사본을 쓴다. + */ +function roomBookmarksQueryOptions(roomId: string | null) { + return queryOptions({ + queryKey: roomAllBookmarksQueryKey(roomId), + queryFn: () => getAllRoomBookmarks(roomId!), + // 본인 변경과 STOMP 무효화로만 갱신한다. 화면 밖에서 무효화된 목록은 다시 마운트될 때 받는다 + staleTime: Infinity, + }); +} + export function useAllRoomBookmarks( roomId: string | null, options?: { enabled?: boolean }, ) { - const queryClient = useQueryClient(); const queryEnabled = options?.enabled ?? true; return useQuery({ - queryKey: roomAllBookmarksQueryKey(roomId), - queryFn: () => fetchAndSeedAllRoomBookmarks(queryClient, roomId!), + ...roomBookmarksQueryOptions(roomId), enabled: !!roomId && queryEnabled, - staleTime: Infinity, - // STOMP가 비활성 캐시를 stale로 표시했다면 queryFn이 등록된 뒤 갱신한다. - refetchOnMount: true, - refetchOnWindowFocus: false, }); } @@ -822,13 +828,10 @@ export function useRoomBookmarks( const idOk = categoryId != null && Number.isFinite(categoryId) && categoryId >= 0; return useQuery({ - queryKey: roomBookmarksQueryKey(roomId, idOk ? categoryId : null), - queryFn: () => getRoomBookmarks(roomId!, categoryId!), + // 카테고리를 고르기 전에는 전체 목록을 걸러 빈 목록을 내지 않고 '조회 전'으로 둔다(빈 상태 문구가 잠깐 보이지 않게) + ...roomBookmarksQueryOptions(idOk ? roomId : null), enabled: !!roomId && idOk, - staleTime: Infinity, - // setQueryData로 시딩된 비활성 캐시는 마운트 시점에만 안전하게 재조회한다. - refetchOnMount: true, - refetchOnWindowFocus: false, + select: (bookmarks) => bookmarks.filter((b) => b.categoryId === categoryId), }); } @@ -950,13 +953,7 @@ export function useMoveRoomBookmark() { categoryId: number; fromCategoryId: number; }) => patchRoomBookmarkCategory(roomId, bookmarkId, { categoryId }), - onSuccess: (_, { roomId, categoryId, fromCategoryId }) => { - queryClient.invalidateQueries({ - queryKey: roomBookmarksQueryKey(roomId, fromCategoryId), - }); - queryClient.invalidateQueries({ - queryKey: roomBookmarksQueryKey(roomId, categoryId), - }); + onSuccess: (_, { roomId }) => { queryClient.invalidateQueries({ queryKey: bookmarkCategoriesQueryKey(roomId), }); @@ -975,10 +972,7 @@ export function useDeleteRoomBookmarkItem() { bookmarkId: number; categoryId: number; }) => deleteRoomBookmark(input.roomId, input.bookmarkId), - onSuccess: (_, { roomId, categoryId }) => { - queryClient.invalidateQueries({ - queryKey: roomBookmarksQueryKey(roomId, categoryId), - }); + onSuccess: (_, { roomId }) => { queryClient.invalidateQueries({ queryKey: bookmarkCategoriesQueryKey(roomId), }); diff --git a/src/lib/api/rooms/bookmarks.ts b/src/lib/api/rooms/bookmarks.ts index e9d3c289..e9976894 100644 --- a/src/lib/api/rooms/bookmarks.ts +++ b/src/lib/api/rooms/bookmarks.ts @@ -32,18 +32,6 @@ function normalizeBookmarksResponse(body: unknown): RoomBookmark[] { return []; } -export async function getRoomBookmarks( - roomId: string, - categoryId: number, -): Promise { - const url = new URL(apiUrl(`/rooms/${roomId}/bookmarks`)); - url.searchParams.set("categoryId", String(categoryId)); - const body = await requestJson(url.toString(), undefined, { - errorMessage: "북마크 목록 조회 실패", - }); - return normalizeBookmarksResponse(body); -} - /** categoryId 없이 방 전체 북마크를 정렬된 목록으로 일괄 조회 */ export async function getAllRoomBookmarks( roomId: string, diff --git a/src/lib/api/rooms/index.ts b/src/lib/api/rooms/index.ts index 5c9eca92..838a37a2 100644 --- a/src/lib/api/rooms/index.ts +++ b/src/lib/api/rooms/index.ts @@ -37,7 +37,6 @@ export { createRoomBookmarks, deleteRoomBookmark, getAllRoomBookmarks, - getRoomBookmarks, patchRoomBookmarkCategory, } from "./bookmarks"; diff --git a/src/lib/bookmarks-invalidation.ts b/src/lib/bookmarks-invalidation.ts index 75c5825a..8300df02 100644 --- a/src/lib/bookmarks-invalidation.ts +++ b/src/lib/bookmarks-invalidation.ts @@ -3,8 +3,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { bookmarkCategoriesQueryKey, roomAllBookmarksQueryKey, - roomBookmarksByRoomRootQueryKey, - roomBookmarksQueryKey, } from "@/lib/query-keys"; import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp"; @@ -16,10 +14,6 @@ async function invalidateAllRoomBookmarkQueries( queryKey: roomAllBookmarksQueryKey(roomId), refetchType: "active", }); - await queryClient.invalidateQueries({ - queryKey: roomBookmarksByRoomRootQueryKey(roomId), - refetchType: "active", - }); } /** STOMP `RoomBookmarkChangedEvent.type`별 최소 범위 무효화·제거만 수행합니다. */ @@ -30,14 +24,8 @@ export async function invalidateRoomBookmarkQueries( const rid = String(event.roomId ?? "").trim(); if (!rid) return; - const categoryId = event.categoryId; - switch (event.type) { case "BOOKMARK_CREATED": - await queryClient.invalidateQueries({ - queryKey: roomBookmarksQueryKey(rid, categoryId), - refetchType: "active", - }); await invalidateAllRoomBookmarkQueries(queryClient, rid); await queryClient.invalidateQueries({ queryKey: bookmarkCategoriesQueryKey(rid), @@ -46,10 +34,6 @@ export async function invalidateRoomBookmarkQueries( break; case "BOOKMARK_UPDATED": - await queryClient.invalidateQueries({ - queryKey: roomBookmarksQueryKey(rid, categoryId), - refetchType: "active", - }); await invalidateAllRoomBookmarkQueries(queryClient, rid); await queryClient.invalidateQueries({ queryKey: bookmarkCategoriesQueryKey(rid), @@ -58,10 +42,6 @@ export async function invalidateRoomBookmarkQueries( break; case "BOOKMARK_DELETED": - await queryClient.invalidateQueries({ - queryKey: roomBookmarksQueryKey(rid, categoryId), - refetchType: "active", - }); await invalidateAllRoomBookmarkQueries(queryClient, rid); await queryClient.invalidateQueries({ queryKey: bookmarkCategoriesQueryKey(rid), @@ -78,9 +58,6 @@ export async function invalidateRoomBookmarkQueries( break; case "CATEGORY_DELETED": - queryClient.removeQueries({ - queryKey: roomBookmarksQueryKey(rid, categoryId), - }); await invalidateAllRoomBookmarkQueries(queryClient, rid); await queryClient.invalidateQueries({ queryKey: bookmarkCategoriesQueryKey(rid), diff --git a/src/lib/bookmarks/bookmark-bulk-cache.ts b/src/lib/bookmarks/bookmark-bulk-cache.ts deleted file mode 100644 index 945902cc..00000000 --- a/src/lib/bookmarks/bookmark-bulk-cache.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { QueryClient } from "@tanstack/react-query"; - -import { getAllRoomBookmarks } from "@/lib/api/rooms/bookmarks"; -import type { RoomBookmark } from "@/lib/api/rooms/types"; -import { - roomAllBookmarksQueryKey, - roomBookmarksQueryKey, -} from "@/lib/query-keys"; - -export function seedRoomBookmarksByCategory( - queryClient: QueryClient, - roomId: string, - allBookmarks: readonly RoomBookmark[], -): void { - const rid = roomId.trim(); - if (!rid.length) return; - - const byCategory = new Map(); - for (const bookmark of allBookmarks) { - const categoryId = bookmark.categoryId; - if (typeof categoryId !== "number" || !Number.isFinite(categoryId)) continue; - const list = byCategory.get(categoryId) ?? []; - list.push(bookmark); - byCategory.set(categoryId, list); - } - - for (const [categoryId, bookmarks] of byCategory) { - queryClient.setQueryData( - roomBookmarksQueryKey(rid, categoryId), - bookmarks, - ); - } -} - -export async function fetchAndSeedAllRoomBookmarks( - queryClient: QueryClient, - roomId: string, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return []; - - const bookmarks = await getAllRoomBookmarks(rid); - queryClient.setQueryData(roomAllBookmarksQueryKey(rid), bookmarks); - seedRoomBookmarksByCategory(queryClient, rid, bookmarks); - return bookmarks; -} diff --git a/src/lib/bookmarks/bookmark-cache-patch.ts b/src/lib/bookmarks/bookmark-cache-patch.ts index 31fbac8c..34d92f27 100644 --- a/src/lib/bookmarks/bookmark-cache-patch.ts +++ b/src/lib/bookmarks/bookmark-cache-patch.ts @@ -4,7 +4,6 @@ import type { BookmarkCategory, RoomBookmark } from "@/lib/api/rooms"; import { bookmarkCategoriesQueryKey, roomAllBookmarksQueryKey, - roomBookmarksQueryKey, } from "@/lib/query-keys"; export function appendBookmarkCategoryToCache( @@ -30,11 +29,11 @@ export function appendBookmarkCategoryToCache( } function mergeBookmarksById( - prev: RoomBookmark[] | undefined, + prev: RoomBookmark[], incoming: readonly RoomBookmark[], ): RoomBookmark[] { const byId = new Map(); - for (const row of prev ?? []) { + for (const row of prev) { byId.set(row.bookmarkId, row); } for (const row of incoming) { @@ -43,6 +42,7 @@ function mergeBookmarksById( return [...byId.values()]; } +/** 추가한 북마크를 방 전체 목록에 합친다. 카테고리별 목록은 이 목록에서 거르므로 따로 쓰지 않는다 */ export function appendRoomBookmarksToCache( queryClient: QueryClient, roomId: string, @@ -53,22 +53,7 @@ export function appendRoomBookmarksToCache( queryClient.setQueryData( roomAllBookmarksQueryKey(rid), - (prev) => mergeBookmarksById(prev, bookmarks), + // 목록을 받은 적이 없으면 일부만 든 목록을 만들지 않고 처음 조회에 맡긴다 + (prev) => prev && mergeBookmarksById(prev, bookmarks), ); - - const byCategory = new Map(); - for (const bookmark of bookmarks) { - const categoryId = bookmark.categoryId; - if (typeof categoryId !== "number" || !Number.isFinite(categoryId)) continue; - const list = byCategory.get(categoryId) ?? []; - list.push(bookmark); - byCategory.set(categoryId, list); - } - - for (const [categoryId, rows] of byCategory) { - queryClient.setQueryData( - roomBookmarksQueryKey(rid, categoryId), - (prev) => mergeBookmarksById(prev, rows), - ); - } } diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index dee035b1..4f9af1a4 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -3,29 +3,15 @@ import type { ScheduleTravelModeValue } from "@/lib/plan/scheduleTravelMode"; // ─── Bookmarks ─────────────────────────────────────────────────────────────── export const BOOKMARK_CATEGORIES_SEGMENT = "bookmark-categories" as const; -export const ROOM_BOOKMARKS_SEGMENT = "room-bookmarks" as const; export const PLACE_CARD_BOOKMARK_SEGMENT = "place-card-bookmark" as const; const BOOKMARK_CATEGORIES = BOOKMARK_CATEGORIES_SEGMENT; -const ROOM_BOOKMARKS = ROOM_BOOKMARKS_SEGMENT; const PLACE_CARD_BOOKMARK = PLACE_CARD_BOOKMARK_SEGMENT; export function bookmarkCategoriesQueryKey(roomId: string | null) { return [BOOKMARK_CATEGORIES, roomId] as const; } -export function roomBookmarksQueryKey( - roomId: string | null, - categoryId: number | null, -) { - return [ROOM_BOOKMARKS, roomId, categoryId] as const; -} - -/** 해당 방의 모든 카테고리별 북마크 목록 쿼리에 공통 접두 */ -export function roomBookmarksByRoomRootQueryKey(roomId: string) { - return [ROOM_BOOKMARKS, roomId] as const; -} - export function roomAllBookmarksQueryKey(roomId: string | null) { return ["room-bookmarks-all", roomId] as const; } diff --git a/src/lib/stomp/room-resync-dispatch.test.ts b/src/lib/stomp/room-resync-dispatch.test.ts index 719f95a0..2ad125df 100644 --- a/src/lib/stomp/room-resync-dispatch.test.ts +++ b/src/lib/stomp/room-resync-dispatch.test.ts @@ -5,6 +5,7 @@ import { expenseKeys } from "@/lib/expenses/expense-queries"; import { bookmarkCategoriesQueryKey, joinRequestsQueryKey, + roomAllBookmarksQueryKey, roomSchedulesQueryKey, roomUnreadCountQueryKey, } from "@/lib/query-keys"; @@ -39,12 +40,15 @@ describe("resyncRoomDataAfterSubscribe", () => { const stop = observer.subscribe(() => {}); await observer.refetch(); client.setQueryData(bookmarkCategoriesQueryKey("room"), []); + client.setQueryData(roomAllBookmarksQueryKey("room"), []); client.setQueryData(roomUnreadCountQueryKey("room"), { unreadCount: 0 }); resyncRoomDataAfterSubscribe(client, "room"); await expect.poll(() => scheduleFetches).toBe(2); expect(client.getQueryState(bookmarkCategoriesQueryKey("room"))?.isInvalidated).toBe(true); + // 카테고리별 북마크는 전체 목록에서 거르므로 전체 목록 하나만 무효화하면 된다 + expect(client.getQueryState(roomAllBookmarksQueryKey("room"))?.isInvalidated).toBe(true); expect(client.getQueryState(roomUnreadCountQueryKey("room"))?.isInvalidated).toBe(true); stop(); }); diff --git a/src/lib/stomp/room-resync-dispatch.ts b/src/lib/stomp/room-resync-dispatch.ts index bf922a99..e8ec3cbb 100644 --- a/src/lib/stomp/room-resync-dispatch.ts +++ b/src/lib/stomp/room-resync-dispatch.ts @@ -4,7 +4,6 @@ import { bookmarkCategoriesQueryKey, joinRequestsRootQueryKey, roomAllBookmarksQueryKey, - roomBookmarksByRoomRootQueryKey, roomSchedulesQueryKey, roomUnreadCountQueryKey, } from "@/lib/query-keys"; @@ -26,7 +25,6 @@ export function resyncRoomDataAfterSubscribe( for (const queryKey of [ roomSchedulesQueryKey(rid), bookmarkCategoriesQueryKey(rid), - roomBookmarksByRoomRootQueryKey(rid), roomAllBookmarksQueryKey(rid), roomUnreadCountQueryKey(rid), ]) { From 81f2d1ab7fab9b5221629ef0ea1a5c7429aae184 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:01:43 +0900 Subject: [PATCH 02/27] =?UTF-8?q?fix(bookmarks):=20STOMP=20=EB=B6=81?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20=EB=AC=B4=ED=9A=A8=ED=99=94=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=91=EB=A0=AC=EB=A1=9C=20=ED=95=98=EA=B3=A0=20=EA=B2=B9?= =?UTF-8?q?=EC=B9=98=EB=8A=94=20=EB=AC=B4=ED=9A=A8=ED=99=94=EB=A5=BC=20?= =?UTF-8?q?=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 타입별로 하나씩 await하던 무효화를 Promise.all로 함께 보낸다(앞 재조회가 끝나야 다음이 출발하던 지연 제거) - 카테고리별 key가 없어져 북마크 변경·카테고리 삭제는 폴더 목록과 전체 북마크 두 key만 무효화한다(invalidateRoomBookmarkLists, 본인 변경도 같이 쓴다) - 기본 refetchType(active)과 같은 옵션은 지운다 - bookmarks-invalidation.test.ts 추가: 타입별 범위, 병렬 출발, 화면 밖 목록 표시만, 다른 방 제외 Co-Authored-By: Claude Opus 5.5 --- src/lib/bookmarks-invalidation.test.ts | 123 +++++++++++++++++++++++++ src/lib/bookmarks-invalidation.ts | 51 ++++------ 2 files changed, 140 insertions(+), 34 deletions(-) create mode 100644 src/lib/bookmarks-invalidation.test.ts diff --git a/src/lib/bookmarks-invalidation.test.ts b/src/lib/bookmarks-invalidation.test.ts new file mode 100644 index 00000000..e0ef2739 --- /dev/null +++ b/src/lib/bookmarks-invalidation.test.ts @@ -0,0 +1,123 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + bookmarkCategoriesQueryKey, + roomAllBookmarksQueryKey, +} from "@/lib/query-keys"; +import type { + RoomBookmarkChangedEvent, + RoomBookmarkChangedType, +} from "@/types/roomBookmarkStomp"; + +import { invalidateRoomBookmarkQueries } from "./bookmarks-invalidation"; + +const ROOM = "room"; + +const event = ( + type: RoomBookmarkChangedType, + roomId = ROOM, +): RoomBookmarkChangedEvent => ({ + type, + roomId, + actorUserId: 2, + bookmarkId: 10, + categoryId: 1, +}); + +const clients: QueryClient[] = []; +const stops: (() => void)[] = []; + +function newClient() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + return client; +} + +/** 화면에 있는 조회처럼 observer를 붙이고, 응답은 테스트가 풀 때까지 붙잡아 둔다 */ +function watch(client: QueryClient, queryKey: readonly unknown[]) { + const pending: (() => void)[] = []; + let fetches = 0; + const observer = new QueryObserver(client, { + queryKey, + queryFn: () => + new Promise((resolve) => { + fetches += 1; + pending.push(() => resolve([])); + }), + staleTime: Infinity, + }); + client.setQueryData(queryKey, []); + stops.push(observer.subscribe(() => {})); + return { + fetches: () => fetches, + resolveAll: () => pending.splice(0).forEach((resolve) => resolve()), + }; +} + +afterEach(() => { + for (const stop of stops.splice(0)) stop(); + for (const client of clients.splice(0)) client.clear(); +}); + +describe("invalidateRoomBookmarkQueries", () => { + it.each([ + "BOOKMARK_CREATED", + "BOOKMARK_UPDATED", + "BOOKMARK_DELETED", + "CATEGORY_DELETED", + ])("%s는 폴더 목록과 전체 북마크를 함께(병렬로) 다시 받는다", async (type) => { + const client = newClient(); + const categories = watch(client, bookmarkCategoriesQueryKey(ROOM)); + const bookmarks = watch(client, roomAllBookmarksQueryKey(ROOM)); + + const done = invalidateRoomBookmarkQueries(client, event(type)); + + // 앞의 재조회가 끝나기 전에 둘 다 출발한다 + await expect.poll(() => [categories.fetches(), bookmarks.fetches()]).toEqual([1, 1]); + categories.resolveAll(); + bookmarks.resolveAll(); + await done; + }); + + it.each(["CATEGORY_CREATED", "CATEGORY_UPDATED"])( + "%s는 폴더 목록만 다시 받는다", + async (type) => { + const client = newClient(); + const categories = watch(client, bookmarkCategoriesQueryKey(ROOM)); + const bookmarks = watch(client, roomAllBookmarksQueryKey(ROOM)); + + const done = invalidateRoomBookmarkQueries(client, event(type)); + + await expect.poll(() => categories.fetches()).toBe(1); + categories.resolveAll(); + await done; + expect(bookmarks.fetches()).toBe(0); + expect(client.getQueryState(roomAllBookmarksQueryKey(ROOM))?.isInvalidated).toBe(false); + }, + ); + + it("화면 밖 목록은 지우지 않고 다시 볼 때 받도록 표시만 한다", async () => { + const client = newClient(); + client.setQueryData(bookmarkCategoriesQueryKey(ROOM), []); + client.setQueryData(roomAllBookmarksQueryKey(ROOM), []); + + await invalidateRoomBookmarkQueries(client, event("CATEGORY_DELETED")); + + expect(client.getQueryData(roomAllBookmarksQueryKey(ROOM))).toEqual([]); + expect(client.getQueryState(bookmarkCategoriesQueryKey(ROOM))?.isInvalidated).toBe(true); + expect(client.getQueryState(roomAllBookmarksQueryKey(ROOM))?.isInvalidated).toBe(true); + }); + + it("다른 방의 목록은 건드리지 않는다", async () => { + const client = newClient(); + client.setQueryData(bookmarkCategoriesQueryKey("other"), []); + client.setQueryData(roomAllBookmarksQueryKey("other"), []); + + await invalidateRoomBookmarkQueries(client, event("BOOKMARK_CREATED")); + await invalidateRoomBookmarkQueries(client, event("BOOKMARK_CREATED", " ")); + + expect(client.getQueryState(bookmarkCategoriesQueryKey("other"))?.isInvalidated).toBe(false); + expect(client.getQueryState(roomAllBookmarksQueryKey("other"))?.isInvalidated).toBe(false); + }); +}); diff --git a/src/lib/bookmarks-invalidation.ts b/src/lib/bookmarks-invalidation.ts index 8300df02..af79732d 100644 --- a/src/lib/bookmarks-invalidation.ts +++ b/src/lib/bookmarks-invalidation.ts @@ -6,17 +6,25 @@ import { } from "@/lib/query-keys"; import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp"; -async function invalidateAllRoomBookmarkQueries( +/** + * 폴더 목록(장소 개수 포함)과 방 전체 북마크를 함께 무효화합니다. 북마크가 바뀌는 STOMP 이벤트와 + * 본인 변경(mutation `onSettled`)이 같이 씁니다. 받는 중인 옛 조회는 TanStack이 취소하고 새로 받습니다. + */ +export async function invalidateRoomBookmarkLists( queryClient: QueryClient, roomId: string, ): Promise { - await queryClient.invalidateQueries({ - queryKey: roomAllBookmarksQueryKey(roomId), - refetchType: "active", - }); + const rid = roomId.trim(); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: bookmarkCategoriesQueryKey(rid) }), + queryClient.invalidateQueries({ queryKey: roomAllBookmarksQueryKey(rid) }), + ]); } -/** STOMP `RoomBookmarkChangedEvent.type`별 최소 범위 무효화·제거만 수행합니다. */ +/** + * STOMP `RoomBookmarkChangedEvent.type`별로 바뀐 범위를 함께 무효화합니다. 화면에 있는 목록만 바로 다시 받고 + * 나머지는 다시 볼 때 받습니다. 카테고리별 북마크는 방 전체 목록에서 거르므로 전체 목록 하나로 충분합니다. + */ export async function invalidateRoomBookmarkQueries( queryClient: QueryClient, event: RoomBookmarkChangedEvent, @@ -26,42 +34,17 @@ export async function invalidateRoomBookmarkQueries( switch (event.type) { case "BOOKMARK_CREATED": - await invalidateAllRoomBookmarkQueries(queryClient, rid); - await queryClient.invalidateQueries({ - queryKey: bookmarkCategoriesQueryKey(rid), - refetchType: "active", - }); - break; - case "BOOKMARK_UPDATED": - await invalidateAllRoomBookmarkQueries(queryClient, rid); - await queryClient.invalidateQueries({ - queryKey: bookmarkCategoriesQueryKey(rid), - refetchType: "active", - }); - break; - case "BOOKMARK_DELETED": - await invalidateAllRoomBookmarkQueries(queryClient, rid); - await queryClient.invalidateQueries({ - queryKey: bookmarkCategoriesQueryKey(rid), - refetchType: "active", - }); + // 카테고리를 지우면 서버가 그 안의 북마크도 함께 지운다 + case "CATEGORY_DELETED": + await invalidateRoomBookmarkLists(queryClient, rid); break; case "CATEGORY_CREATED": case "CATEGORY_UPDATED": await queryClient.invalidateQueries({ queryKey: bookmarkCategoriesQueryKey(rid), - refetchType: "active", - }); - break; - - case "CATEGORY_DELETED": - await invalidateAllRoomBookmarkQueries(queryClient, rid); - await queryClient.invalidateQueries({ - queryKey: bookmarkCategoriesQueryKey(rid), - refetchType: "active", }); break; From 5d7c52555071ac46903a4119578c8dd387f26a56 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:03:30 +0900 Subject: [PATCH 03/27] =?UTF-8?q?fix(bookmarks):=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=92=A4=20=EC=A0=84=EC=B2=B4=20=EB=B6=81?= =?UTF-8?q?=EB=A7=88=ED=81=AC=EC=99=80=20=ED=8F=B4=EB=8D=94=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EB=B0=9B=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버는 폴더 안의 북마크도 함께 지우는데, 폴더 목록만 무효화해 지도 핀·전체 북마크에 지운 북마크가 남았다 - 본인 CATEGORY_DELETED는 STOMP에서 버리므로 onSettled(성공·실패 모두)에서 두 목록을 다시 받는다 - 응답 본문이 없어 재조회를 반환해 목록에서 폴더가 빠질 때까지 확인창 진행 표시를 유지한다 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useBookmarkCategories.test.tsx | 46 +++++++++++++++++++++--- src/hooks/useRooms.ts | 10 +++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/hooks/useBookmarkCategories.test.tsx b/src/hooks/useBookmarkCategories.test.tsx index a265380f..2a4a04ed 100644 --- a/src/hooks/useBookmarkCategories.test.tsx +++ b/src/hooks/useBookmarkCategories.test.tsx @@ -3,11 +3,11 @@ import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { expect, it, vi } from 'vitest'; -import { bookmarkCategoriesQueryKey } from '@/lib/query-keys'; -import { useBookmarkCategories } from './useRooms'; +import { bookmarkCategoriesQueryKey, roomAllBookmarksQueryKey } from '@/lib/query-keys'; +import { useAllRoomBookmarks, useBookmarkCategories, useDeleteBookmarkCategory } from './useRooms'; import { applyRoomBookmarkStompMessage } from '@/lib/stomp/bookmarks-dispatch'; -const api = vi.hoisted(() => ({ get: vi.fn() })); -vi.mock('@/lib/api/rooms', async importOriginal => ({...await importOriginal(), getBookmarkCategories: api.get})); +const api = vi.hoisted(() => ({ get: vi.fn(), getAll: vi.fn(), deleteCategory: vi.fn() })); +vi.mock('@/lib/api/rooms', async importOriginal => ({...await importOriginal(), getBookmarkCategories: api.get, getAllRoomBookmarks: api.getAll, deleteBookmarkCategory: api.deleteCategory})); function Probe() { const query=useBookmarkCategories('room');return

{query.data?.[0]?.name}

; } it('reuses folders on reentry but fetches changes broadcast while mounted or away', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true); @@ -32,3 +32,41 @@ it('reuses folders on reentry but fetches changes broadcast while mounted or awa expect(api.get).toHaveBeenCalledTimes(3); } finally {await act(async()=>root.unmount());client.clear();vi.unstubAllGlobals();} }); + +function renderHooks(client: QueryClient, useValue: () => T) { + const result = { current: undefined as T }; + const capture = (value: T) => { result.current = value; }; + function Probe({ onRender }: { onRender: (value: T) => void }) { onRender(useValue()); return null; } + const host = document.createElement('div'); + const root = createRoot(host); + return { + result, + mount: () => act(async () => root.render()), + unmount: () => act(async () => root.unmount()), + }; +} + +it('폴더를 지우면 폴더 목록과 그 안의 북마크가 빠진 뒤에 끝난다', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + api.get.mockResolvedValue([{ categoryId: 1, name: '맛집', placeCount: 1 }, { categoryId: 2, name: '카페', placeCount: 0 }]); + api.getAll.mockResolvedValue([{ bookmarkId: 10, categoryId: 1 }]); + api.deleteCategory.mockResolvedValue(undefined); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = renderHooks(client, () => ({ + categories: useBookmarkCategories('room'), + bookmarks: useAllRoomBookmarks('room'), + remove: useDeleteBookmarkCategory(), + })); + try { + await view.mount(); + await vi.waitFor(() => expect(view.result.current.bookmarks.data).toHaveLength(1)); + api.get.mockResolvedValue([{ categoryId: 2, name: '카페', placeCount: 0 }]); + api.getAll.mockResolvedValue([]); + + await act(async () => { await view.result.current.remove.mutateAsync({ roomId: 'room', categoryId: 1 }); }); + + // 본인 CATEGORY_DELETED는 STOMP에서 버리므로 mutation이 두 목록을 다시 받고, 받은 뒤에 끝난다 + expect(client.getQueryData(bookmarkCategoriesQueryKey('room'))).toEqual([{ categoryId: 2, name: '카페', placeCount: 0 }]); + expect(client.getQueryData(roomAllBookmarksQueryKey('room'))).toEqual([]); + } finally { await view.unmount(); client.clear(); vi.unstubAllGlobals(); } +}); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 81c1e5f4..10d60ea1 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -71,6 +71,7 @@ import { appendBookmarkCategoryToCache, appendRoomBookmarksToCache, } from "@/lib/bookmarks/bookmark-cache-patch"; +import { invalidateRoomBookmarkLists } from "@/lib/bookmarks-invalidation"; import { bookmarkCategoriesQueryKey, joinRequestsQueryKey, @@ -789,11 +790,10 @@ export function useDeleteBookmarkCategory() { roomId: string; categoryId: number; }) => deleteBookmarkCategory(roomId, categoryId), - onSuccess: (_, { roomId }) => { - queryClient.invalidateQueries({ - queryKey: bookmarkCategoriesQueryKey(roomId), - }); - }, + // 서버가 폴더 안의 북마크도 함께 지운다. 본인 CATEGORY_DELETED는 STOMP에서 버리므로 여기서 둘 다 다시 받는다. + // 응답 본문이 없어 목록에서 폴더가 빠질 때까지 기다린다 — 확인창이 닫힌 뒤 지운 폴더가 잠깐 남지 않게 + onSettled: (_data, _error, { roomId }) => + invalidateRoomBookmarkLists(queryClient, roomId), }); } From 85279ee6a880140fb254e3b921436e5f24d4a7f1 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:03:55 +0900 Subject: [PATCH 04/27] =?UTF-8?q?fix(bookmarks):=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=EC=97=90=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EA=B0=80=20=EC=8B=A4=ED=8C=A8=ED=95=B4?= =?UTF-8?q?=EB=8F=84=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mutationFn이 오류를 결과 객체로 바꿔 삼켜 실패·409(이미 있음) 경로에 재조회가 없었다 - onSettled에서 폴더 목록과 전체 북마크를 무효화한다. 성공 응답은 onSuccess에서 이미 반영해 기다리지 않는다(void) - "목록 캐시도 즉시 무효화" 주석을 실제 동작(응답 반영 → 끝나면 재조회)에 맞게 고친다 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useBookmarkCategories.test.tsx | 26 +++++++++++++++++++++--- src/hooks/useRooms.ts | 9 +++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/hooks/useBookmarkCategories.test.tsx b/src/hooks/useBookmarkCategories.test.tsx index 2a4a04ed..2afec531 100644 --- a/src/hooks/useBookmarkCategories.test.tsx +++ b/src/hooks/useBookmarkCategories.test.tsx @@ -4,10 +4,10 @@ import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { expect, it, vi } from 'vitest'; import { bookmarkCategoriesQueryKey, roomAllBookmarksQueryKey } from '@/lib/query-keys'; -import { useAllRoomBookmarks, useBookmarkCategories, useDeleteBookmarkCategory } from './useRooms'; +import { useAllRoomBookmarks, useBookmarkCategories, useCreateRoomBookmarksInCategories, useDeleteBookmarkCategory } from './useRooms'; import { applyRoomBookmarkStompMessage } from '@/lib/stomp/bookmarks-dispatch'; -const api = vi.hoisted(() => ({ get: vi.fn(), getAll: vi.fn(), deleteCategory: vi.fn() })); -vi.mock('@/lib/api/rooms', async importOriginal => ({...await importOriginal(), getBookmarkCategories: api.get, getAllRoomBookmarks: api.getAll, deleteBookmarkCategory: api.deleteCategory})); +const api = vi.hoisted(() => ({ get: vi.fn(), getAll: vi.fn(), deleteCategory: vi.fn(), createBookmarks: vi.fn() })); +vi.mock('@/lib/api/rooms', async importOriginal => ({...await importOriginal(), getBookmarkCategories: api.get, getAllRoomBookmarks: api.getAll, deleteBookmarkCategory: api.deleteCategory, createRoomBookmarks: api.createBookmarks})); function Probe() { const query=useBookmarkCategories('room');return

{query.data?.[0]?.name}

; } it('reuses folders on reentry but fetches changes broadcast while mounted or away', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true); @@ -70,3 +70,23 @@ it('폴더를 지우면 폴더 목록과 그 안의 북마크가 빠진 뒤에 expect(client.getQueryData(roomAllBookmarksQueryKey('room'))).toEqual([]); } finally { await view.unmount(); client.clear(); vi.unstubAllGlobals(); } }); + +it('여러 폴더에 북마크 추가가 실패해도 두 목록을 다시 받도록 표시한다', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + api.createBookmarks.mockRejectedValue(new Error('network')); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(bookmarkCategoriesQueryKey('room'), [{ categoryId: 1, name: '맛집', placeCount: 0 }]); + client.setQueryData(roomAllBookmarksQueryKey('room'), []); + const view = renderHooks(client, () => useCreateRoomBookmarksInCategories()); + try { + await view.mount(); + let result: Awaited> | undefined; + await act(async () => { result = await view.result.current.mutateAsync({ roomId: 'room', googlePlaceId: 'place', categoryIds: [1] }); }); + + // 오류는 결과 객체로 돌려주므로(throw 없음) onSettled가 실패 경로도 맡는다 + expect(result?.firstHardError?.message).toBe('network'); + expect(client.getQueryData(roomAllBookmarksQueryKey('room'))).toEqual([]); + expect(client.getQueryState(bookmarkCategoriesQueryKey('room'))?.isInvalidated).toBe(true); + expect(client.getQueryState(roomAllBookmarksQueryKey('room'))?.isInvalidated).toBe(true); + } finally { await view.unmount(); client.clear(); vi.unstubAllGlobals(); } +}); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 10d60ea1..732e366b 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -874,7 +874,10 @@ export type CreateRoomBookmarksInCategoriesResult = { bookmarks: RoomBookmark[]; }; -/** POST 한 번으로 `googlePlaceId` + `categoryIds` 배열 전달. STOMP와 함께 목록 캐시도 즉시 무효화합니다. */ +/** + * POST 한 번으로 `googlePlaceId` + `categoryIds` 배열 전달. 추가된 북마크는 응답으로 전체 목록·폴더 개수에 바로 넣고, + * 끝나면 두 목록을 서버 기준으로 다시 받습니다. 오류도 결과 객체로 돌려줘(throw 없음) 실패한 추가도 `onSettled`가 맞춥니다. + */ export function useCreateRoomBookmarksInCategories() { const queryClient = useQueryClient(); return useMutation({ @@ -937,6 +940,10 @@ export function useCreateRoomBookmarksInCategories() { ); appendRoomBookmarksToCache(queryClient, roomId, result.bookmarks); }, + // 응답을 이미 반영했으므로 기다리지 않는다(대화상자는 바로 닫힌다). 409(이미 있음)·실패도 다른 탭·사람의 추가일 수 있어 다시 받는다 + onSettled: (_result, _error, { roomId }) => { + void invalidateRoomBookmarkLists(queryClient, roomId); + }, }); } From e7b9cb616225c7a48c6af4ae473d0dec07553b62 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:04:13 +0900 Subject: [PATCH 05/27] =?UTF-8?q?chore(bookmarks):=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=8B=A8=EA=B1=B4=20?= =?UTF-8?q?=EB=B6=81=EB=A7=88=ED=81=AC=20=EC=B6=94=EA=B0=80=20=ED=9B=85?= =?UTF-8?q?=EC=9D=84=20=EC=A7=80=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCreateRoomBookmark는 호출부가 없다. 추가는 useCreateRoomBookmarksInCategories 하나로 한다 - 함께 쓰이지 않게 된 createRoomBookmark API와 RoomBookmarkCreateRequest 타입도 지운다 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useRooms.ts | 30 ------------------------------ src/lib/api/rooms/bookmarks.ts | 16 ---------------- src/lib/api/rooms/index.ts | 2 -- src/lib/api/rooms/types.ts | 5 ----- 4 files changed, 53 deletions(-) diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 732e366b..c7304490 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -17,7 +17,6 @@ import { HttpError, approveJoinRequest, createBookmarkCategory, - createRoomBookmark, createRoomBookmarks, createRoom, createRoomSchedule, @@ -835,35 +834,6 @@ export function useRoomBookmarks( }); } -export function useCreateRoomBookmark() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ - roomId, - googlePlaceId, - categoryId, - }: { - roomId: string; - googlePlaceId: string; - categoryId: number; - }) => createRoomBookmark(roomId, { googlePlaceId, categoryId }), - onSuccess: (created, { roomId, categoryId }) => { - queryClient.setQueryData( - bookmarkCategoriesQueryKey(roomId), - (prev) => { - if (!prev?.length) return prev; - return prev.map((c) => - c.categoryId === categoryId - ? { ...c, placeCount: c.placeCount + 1 } - : c, - ); - }, - ); - appendRoomBookmarksToCache(queryClient, roomId, [created]); - }, - }); -} - export type CreateRoomBookmarksInCategoriesResult = { added: number; skippedDuplicate: number; diff --git a/src/lib/api/rooms/bookmarks.ts b/src/lib/api/rooms/bookmarks.ts index e9976894..46b2412c 100644 --- a/src/lib/api/rooms/bookmarks.ts +++ b/src/lib/api/rooms/bookmarks.ts @@ -15,7 +15,6 @@ import type { RoomBookmark, RoomBookmarkBulkCreateRequest, RoomBookmarkCategoryPatchRequest, - RoomBookmarkCreateRequest, } from "./types"; function normalizeBookmarksResponse(body: unknown): RoomBookmark[] { @@ -77,21 +76,6 @@ export async function createRoomBookmarks( return normalizeBookmarksResponse(parsed); } -export async function createRoomBookmark( - roomId: string, - body: RoomBookmarkCreateRequest, -): Promise { - const list = await createRoomBookmarks(roomId, { - googlePlaceId: body.googlePlaceId, - categoryIds: [body.categoryId], - }); - const first = list[0]; - if (!first) { - throw new HttpError(409, "이미 북마크에 추가된 장소입니다"); - } - return first; -} - export async function patchRoomBookmarkCategory( roomId: string, bookmarkId: number, diff --git a/src/lib/api/rooms/index.ts b/src/lib/api/rooms/index.ts index 838a37a2..21899734 100644 --- a/src/lib/api/rooms/index.ts +++ b/src/lib/api/rooms/index.ts @@ -10,7 +10,6 @@ export type { RoomBookmark, RoomBookmarkBulkCreateRequest, RoomBookmarkCategoryPatchRequest, - RoomBookmarkCreateRequest, RoomCreateRequest, RoomCreateResponse, RoomDetail, @@ -33,7 +32,6 @@ export { } from "./bookmark-categories"; export { - createRoomBookmark, createRoomBookmarks, deleteRoomBookmark, getAllRoomBookmarks, diff --git a/src/lib/api/rooms/types.ts b/src/lib/api/rooms/types.ts index ebabf2ef..ec21ba36 100644 --- a/src/lib/api/rooms/types.ts +++ b/src/lib/api/rooms/types.ts @@ -140,11 +140,6 @@ export type RoomBookmark = { createdAt: string; }; -export type RoomBookmarkCreateRequest = { - googlePlaceId: string; - categoryId: number; -}; - /** POST /rooms/:roomId/bookmarks — 여러 카테고리에 동일 장소를 한 번에 추가 */ export type RoomBookmarkBulkCreateRequest = { googlePlaceId: string; From 1e00b6df82db5b14ac4867133d94c32a7410a7fb Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:05:15 +0900 Subject: [PATCH 06/27] =?UTF-8?q?refactor(bookmarks):=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=C2=B7=ED=8F=B4=EB=8D=94=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=92=A4=20onSettled=EC=97=90=EC=84=9C=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 폴더 생성·수정, 북마크 이동·삭제에 onSettled(성공·실패 모두) 무효화를 넣는다. 본인 STOMP 반향은 버려 바꾼 탭은 여기서 맞춘다 - 응답이 있는 것은 onSuccess에서 반영하고 재조회는 기다리지 않는다(void): 폴더 생성(새 폴더 추가), 폴더 수정(이름·색만 — 응답 placeCount는 0), 이동(옮긴 북마크로 교체) - 응답이 없는 북마크 삭제는 재조회를 반환해 목록에서 빠질 때까지 확인창 진행 표시를 유지한다 - 폴더 목록을 받은 적이 없으면 새 폴더 한 건짜리 목록을 만들지 않는다 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useBookmarkCategories.test.tsx | 43 +++++++++++++++++-- src/hooks/useRooms.ts | 52 +++++++++++++++-------- src/lib/bookmarks/bookmark-cache-patch.ts | 3 +- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/hooks/useBookmarkCategories.test.tsx b/src/hooks/useBookmarkCategories.test.tsx index 2afec531..4ae5ac6a 100644 --- a/src/hooks/useBookmarkCategories.test.tsx +++ b/src/hooks/useBookmarkCategories.test.tsx @@ -4,10 +4,10 @@ import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { expect, it, vi } from 'vitest'; import { bookmarkCategoriesQueryKey, roomAllBookmarksQueryKey } from '@/lib/query-keys'; -import { useAllRoomBookmarks, useBookmarkCategories, useCreateRoomBookmarksInCategories, useDeleteBookmarkCategory } from './useRooms'; +import { useAllRoomBookmarks, useBookmarkCategories, useCreateRoomBookmarksInCategories, useDeleteBookmarkCategory, useMoveRoomBookmark, useUpdateBookmarkCategory } from './useRooms'; import { applyRoomBookmarkStompMessage } from '@/lib/stomp/bookmarks-dispatch'; -const api = vi.hoisted(() => ({ get: vi.fn(), getAll: vi.fn(), deleteCategory: vi.fn(), createBookmarks: vi.fn() })); -vi.mock('@/lib/api/rooms', async importOriginal => ({...await importOriginal(), getBookmarkCategories: api.get, getAllRoomBookmarks: api.getAll, deleteBookmarkCategory: api.deleteCategory, createRoomBookmarks: api.createBookmarks})); +const api = vi.hoisted(() => ({ get: vi.fn(), getAll: vi.fn(), deleteCategory: vi.fn(), createBookmarks: vi.fn(), updateCategory: vi.fn(), moveBookmark: vi.fn() })); +vi.mock('@/lib/api/rooms', async importOriginal => ({...await importOriginal(), getBookmarkCategories: api.get, getAllRoomBookmarks: api.getAll, deleteBookmarkCategory: api.deleteCategory, createRoomBookmarks: api.createBookmarks, updateBookmarkCategory: api.updateCategory, patchRoomBookmarkCategory: api.moveBookmark})); function Probe() { const query=useBookmarkCategories('room');return

{query.data?.[0]?.name}

; } it('reuses folders on reentry but fetches changes broadcast while mounted or away', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true); @@ -90,3 +90,40 @@ it('여러 폴더에 북마크 추가가 실패해도 두 목록을 다시 받 expect(client.getQueryState(roomAllBookmarksQueryKey('room'))?.isInvalidated).toBe(true); } finally { await view.unmount(); client.clear(); vi.unstubAllGlobals(); } }); + +it('폴더 이름을 고치면 장소 개수는 두고 이름·색만 바로 바꾼 뒤 폴더 목록을 다시 받도록 표시한다', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + // 수정 응답의 placeCount는 세지 않은 값(0)이다 + api.updateCategory.mockResolvedValue({ categoryId: 1, name: '새 이름', colorCode: '#000000', placeCount: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(bookmarkCategoriesQueryKey('room'), [{ categoryId: 1, name: '맛집', colorCode: '#ffffff', placeCount: 3 }]); + const view = renderHooks(client, () => useUpdateBookmarkCategory()); + try { + await view.mount(); + await act(async () => { await view.result.current.mutateAsync({ roomId: 'room', categoryId: 1, name: '새 이름', colorCode: '#000000' }); }); + + expect(client.getQueryData(bookmarkCategoriesQueryKey('room'))).toEqual([{ categoryId: 1, name: '새 이름', colorCode: '#000000', placeCount: 3 }]); + expect(client.getQueryState(bookmarkCategoriesQueryKey('room'))?.isInvalidated).toBe(true); + } finally { await view.unmount(); client.clear(); vi.unstubAllGlobals(); } +}); + +it('북마크를 다른 폴더로 옮기면 지금 폴더에서 바로 빠지고, 실패해도 두 목록을 다시 받도록 표시한다', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(bookmarkCategoriesQueryKey('room'), []); + client.setQueryData(roomAllBookmarksQueryKey('room'), [{ bookmarkId: 10, categoryId: 1 }]); + const view = renderHooks(client, () => useMoveRoomBookmark()); + try { + await view.mount(); + api.moveBookmark.mockResolvedValueOnce({ bookmarkId: 10, categoryId: 2 }); + await act(async () => { await view.result.current.mutateAsync({ roomId: 'room', bookmarkId: 10, categoryId: 2, fromCategoryId: 1 }); }); + expect(client.getQueryData(roomAllBookmarksQueryKey('room'))).toEqual([{ bookmarkId: 10, categoryId: 2 }]); + + client.setQueryData(bookmarkCategoriesQueryKey('room'), []); + client.setQueryData(roomAllBookmarksQueryKey('room'), [{ bookmarkId: 10, categoryId: 2 }]); + api.moveBookmark.mockRejectedValueOnce(new Error('conflict')); + await act(async () => { await expect(view.result.current.mutateAsync({ roomId: 'room', bookmarkId: 10, categoryId: 1, fromCategoryId: 2 })).rejects.toThrow('conflict'); }); + expect(client.getQueryState(bookmarkCategoriesQueryKey('room'))?.isInvalidated).toBe(true); + expect(client.getQueryState(roomAllBookmarksQueryKey('room'))?.isInvalidated).toBe(true); + } finally { await view.unmount(); client.clear(); vi.unstubAllGlobals(); } +}); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index c7304490..41ef5622 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -754,6 +754,12 @@ export function useCreateBookmarkCategory() { onSuccess: (created, { roomId }) => { appendBookmarkCategoryToCache(queryClient, roomId, created); }, + // 응답을 이미 넣었으므로 기다리지 않는다(대화상자는 바로 닫힌다). 본인 STOMP 반향은 버려 바꾼 탭은 여기서 맞춘다 + onSettled: (_created, _error, { roomId }) => { + void queryClient.invalidateQueries({ + queryKey: bookmarkCategoriesQueryKey(roomId), + }); + }, }); } @@ -771,8 +777,21 @@ export function useUpdateBookmarkCategory() { name: string; colorCode: string; }) => updateBookmarkCategory(roomId, categoryId, { name, colorCode }), - onSuccess: (_, { roomId }) => { - queryClient.invalidateQueries({ + // 수정 응답의 placeCount는 세지 않은 값(0)이라 이름·색만 반영한다 + onSuccess: (updated, { roomId, categoryId }) => { + queryClient.setQueryData( + bookmarkCategoriesQueryKey(roomId), + (prev) => + prev?.map((c) => + c.categoryId === categoryId + ? { ...c, name: updated.name, colorCode: updated.colorCode } + : c, + ), + ); + }, + // 응답을 이미 반영했으므로 기다리지 않는다(대화상자는 바로 닫힌다) + onSettled: (_updated, _error, { roomId }) => { + void queryClient.invalidateQueries({ queryKey: bookmarkCategoriesQueryKey(roomId), }); }, @@ -930,13 +949,17 @@ export function useMoveRoomBookmark() { categoryId: number; fromCategoryId: number; }) => patchRoomBookmarkCategory(roomId, bookmarkId, { categoryId }), - onSuccess: (_, { roomId }) => { - queryClient.invalidateQueries({ - queryKey: bookmarkCategoriesQueryKey(roomId), - }); - queryClient.invalidateQueries({ - queryKey: roomAllBookmarksQueryKey(roomId), - }); + // 옮긴 북마크로 바꿔 넣어 지금 폴더 목록에서 바로 빠지게 한다 + onSuccess: (moved, { roomId }) => { + queryClient.setQueryData( + roomAllBookmarksQueryKey(roomId), + (prev) => + prev?.map((b) => (b.bookmarkId === moved.bookmarkId ? moved : b)), + ); + }, + // 응답을 이미 반영했으므로 기다리지 않는다(메뉴는 바로 닫힌다). 폴더별 장소 개수와 정렬은 다시 받아 맞춘다 + onSettled: (_moved, _error, { roomId }) => { + void invalidateRoomBookmarkLists(queryClient, roomId); }, }); } @@ -949,13 +972,8 @@ export function useDeleteRoomBookmarkItem() { bookmarkId: number; categoryId: number; }) => deleteRoomBookmark(input.roomId, input.bookmarkId), - onSuccess: (_, { roomId }) => { - queryClient.invalidateQueries({ - queryKey: bookmarkCategoriesQueryKey(roomId), - }); - queryClient.invalidateQueries({ - queryKey: roomAllBookmarksQueryKey(roomId), - }); - }, + // 응답 본문이 없어 목록에서 빠질 때까지 기다린다 — 확인창이 닫힌 뒤 지운 장소가 잠깐 남지 않게 + onSettled: (_data, _error, { roomId }) => + invalidateRoomBookmarkLists(queryClient, roomId), }); } diff --git a/src/lib/bookmarks/bookmark-cache-patch.ts b/src/lib/bookmarks/bookmark-cache-patch.ts index 34d92f27..1356e293 100644 --- a/src/lib/bookmarks/bookmark-cache-patch.ts +++ b/src/lib/bookmarks/bookmark-cache-patch.ts @@ -17,7 +17,8 @@ export function appendBookmarkCategoryToCache( queryClient.setQueryData( bookmarkCategoriesQueryKey(rid), (prev) => { - if (!prev?.length) return [category]; + // 목록을 받은 적이 없으면 한 건짜리 목록을 만들지 않고 처음 조회에 맡긴다 + if (!prev) return prev; if (prev.some((c) => c.categoryId === category.categoryId)) { return prev.map((c) => c.categoryId === category.categoryId ? category : c, From c2b4aae13ada02501ae117e6dc13ace3345df389 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 8 Nov 2026 16:14:38 +0900 Subject: [PATCH 07/27] =?UTF-8?q?test(bookmarks):=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=B3=84=20=EB=B6=81=EB=A7=88=ED=81=AC=EA=B0=80=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=AA=A9=EB=A1=9D=20=ED=95=9C=20=EB=B2=88=EC=9D=84?= =?UTF-8?q?=20=EA=B1=B8=EB=9F=AC=20=EC=93=B0=EB=8A=94=EC=A7=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 폴더를 바꿔도 전체 목록을 다시 받지 않고 isPending이 false인지, 폴더별로 걸러지는지 단언한다 - 폴더를 고르기 전(null)에는 요청 없이 '조회 전'으로 두는지 단언한다 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useBookmarkCategories.test.tsx | 31 +++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/hooks/useBookmarkCategories.test.tsx b/src/hooks/useBookmarkCategories.test.tsx index 4ae5ac6a..939a308f 100644 --- a/src/hooks/useBookmarkCategories.test.tsx +++ b/src/hooks/useBookmarkCategories.test.tsx @@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { expect, it, vi } from 'vitest'; import { bookmarkCategoriesQueryKey, roomAllBookmarksQueryKey } from '@/lib/query-keys'; -import { useAllRoomBookmarks, useBookmarkCategories, useCreateRoomBookmarksInCategories, useDeleteBookmarkCategory, useMoveRoomBookmark, useUpdateBookmarkCategory } from './useRooms'; +import { useAllRoomBookmarks, useBookmarkCategories, useCreateRoomBookmarksInCategories, useDeleteBookmarkCategory, useMoveRoomBookmark, useRoomBookmarks, useUpdateBookmarkCategory } from './useRooms'; import { applyRoomBookmarkStompMessage } from '@/lib/stomp/bookmarks-dispatch'; const api = vi.hoisted(() => ({ get: vi.fn(), getAll: vi.fn(), deleteCategory: vi.fn(), createBookmarks: vi.fn(), updateCategory: vi.fn(), moveBookmark: vi.fn() })); vi.mock('@/lib/api/rooms', async importOriginal => ({...await importOriginal(), getBookmarkCategories: api.get, getAllRoomBookmarks: api.getAll, deleteBookmarkCategory: api.deleteCategory, createRoomBookmarks: api.createBookmarks, updateBookmarkCategory: api.updateCategory, patchRoomBookmarkCategory: api.moveBookmark})); @@ -127,3 +127,32 @@ it('북마크를 다른 폴더로 옮기면 지금 폴더에서 바로 빠지고 expect(client.getQueryState(roomAllBookmarksQueryKey('room'))?.isInvalidated).toBe(true); } finally { await view.unmount(); client.clear(); vi.unstubAllGlobals(); } }); + +it('폴더별 북마크는 전체 목록 한 번을 걸러 쓰고, 폴더를 바꿔도 다시 받지 않는다', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + api.getAll.mockReset().mockResolvedValue([ + { bookmarkId: 10, categoryId: 1, googlePlaceId: 'a' }, + { bookmarkId: 11, categoryId: 2, googlePlaceId: 'b' }, + { bookmarkId: 12, categoryId: 1, googlePlaceId: 'c' }, + ]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + let categoryId: number | null = 1; + const view = renderHooks(client, () => useRoomBookmarks('room', categoryId)); + try { + await view.mount(); + await vi.waitFor(() => expect(view.result.current.data?.map((b) => b.bookmarkId)).toEqual([10, 12])); + + categoryId = 2; + await view.mount(); + expect(view.result.current.isPending).toBe(false); + expect(view.result.current.data?.map((b) => b.bookmarkId)).toEqual([11]); + expect(api.getAll).toHaveBeenCalledTimes(1); + + // 폴더를 고르기 전에는 빈 목록이 아니라 '조회 전'으로 둔다(빈 상태 문구가 잠깐 보이지 않게) + categoryId = null; + await view.mount(); + expect(view.result.current.isPending).toBe(true); + expect(view.result.current.data).toBeUndefined(); + expect(api.getAll).toHaveBeenCalledTimes(1); + } finally { await view.unmount(); client.clear(); vi.unstubAllGlobals(); } +}); From eb2b0f7561e8bfbb8bb7b09a7bcabfeed07d9c45 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:09:21 +0900 Subject: [PATCH 08/27] =?UTF-8?q?fix(members):=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A1=9C=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=ED=8C=A8=EC=B9=98=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B3=A0=20=EC=84=9C=EB=B2=84=EC=97=90=EC=84=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=9B=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이벤트 detail로 room-members를 고치면 이벤트 전에 출발한 GET이 늦게 와서 덮었다. 무효화로 바꿔 TanStack이 옛 조회를 취소하게 한다 - 내가 나가거나 내보내진 이벤트(또는 대상을 모르는 이벤트)는 다시 받지 않는다. 나간 방의 GET은 403이고, 방 캐시는 나가기·강퇴 처리가 지운다 - 방장 위임은 방장 판정의 세 출처(멤버·방 목록·방 상세)를 모두 무효화한다 — 다른 탭의 이전 방장도 방장 판정이 풀린다 - members-dispatch.test.ts: 무효화·늦은 응답 무시·본인 퇴장 제외·방장 위임 다른 탭 시나리오로 다시 씀 Co-Authored-By: Claude Opus 5.5 --- src/lib/stomp/members-dispatch.test.ts | 176 +++++++++++++++++---- src/lib/stomp/members-dispatch.tsx | 208 ++++--------------------- 2 files changed, 171 insertions(+), 213 deletions(-) diff --git a/src/lib/stomp/members-dispatch.test.ts b/src/lib/stomp/members-dispatch.test.ts index c915d8fb..377ddc23 100644 --- a/src/lib/stomp/members-dispatch.test.ts +++ b/src/lib/stomp/members-dispatch.test.ts @@ -1,76 +1,190 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { afterEach, describe, expect, it } from "vitest"; -import type { RoomMember, RoomMemberListResponse } from "@/lib/api/rooms"; -import { roomMembersQueryKey } from "@/lib/query-keys"; +import type { + RoomDetail, + RoomListResponse, + RoomMember, + RoomMemberListResponse, +} from "@/lib/api/rooms"; +import { expenseKeys } from "@/lib/expenses/expense-queries"; +import { + ROOMS_QUERY_KEY, + roomDetailQueryKey, + roomMembersQueryKey, + sessionUserQueryKey, +} from "@/lib/query-keys"; +import { resolveViewerIsHost } from "@/lib/rooms"; import type { RoomMemberPayload } from "@/lib/stomp/member-events"; import { dispatchRoomMemberEvent } from "@/lib/stomp/members-dispatch"; -import { dispatchRoomPresence } from "@/lib/stomp/presence-dispatch"; const ROOM_ID = "room"; const KEY = roomMembersQueryKey(ROOM_ID); +const ME = 1; -const member = (userId: number, isOnline = false): RoomMember => ({ +const member = (userId: number, role: RoomMember["role"] = "MEMBER"): RoomMember => ({ userId, nickname: `user-${userId}`, profileImageUrl: null, - role: "MEMBER", + role, status: "ACTIVE", joinedAt: "2026-10-25T00:00:00Z", - isOnline, + isOnline: false, }); +const base = { id: "event", roomId: ROOM_ID, createdAt: "2026-10-25T00:00:00Z" }; const joined = (userId: number): RoomMemberPayload => ({ + ...base, type: "MEMBER_JOINED", - id: `event-${userId}`, - roomId: ROOM_ID, - createdAt: "2026-10-25T00:00:00Z", detail: { userId, nickname: `user-${userId}` }, }); +const removed = (type: "MEMBER_LEFT" | "MEMBER_KICKED", userId: number): RoomMemberPayload => ({ + ...base, + type, + detail: { userId }, +}); +const delegated = (previousHostUserId: number, newHostUserId: number): RoomMemberPayload => ({ + ...base, + type: "HOST_DELEGATED", + detail: { previousHostUserId, newHostUserId }, +}); const clients: QueryClient[] = []; -function clientWith(data?: RoomMemberListResponse) { - const client = new QueryClient(); +const stops: (() => void)[] = []; + +function newClient() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); - if (data) client.setQueryData(KEY, data); + client.setQueryData(sessionUserQueryKey, { id: ME, nickname: "나", profileImageUrl: null }); return client; } +/** 화면에 있는 조회처럼 observer를 붙인다. 응답은 `respond`가 정한다 */ +function watch(client: QueryClient, queryKey: readonly unknown[], respond: () => Promise) { + let fetches = 0; + const observer = new QueryObserver(client, { + queryKey, + queryFn: () => { + fetches += 1; + return respond(); + }, + staleTime: Infinity, + }); + stops.push(observer.subscribe(() => {})); + return { fetches: () => fetches }; +} + afterEach(() => { + for (const stop of stops.splice(0)) stop(); for (const client of clients.splice(0)) client.clear(); }); -describe("member cache patches", () => { - it("applies a member event to a valid list", () => { - const client = clientWith({ members: [member(1)] }); +describe("dispatchRoomMemberEvent", () => { + it("다른 사람의 입장은 목록을 고치지 않고 다시 받는다", async () => { + const client = newClient(); + client.setQueryData(KEY, { members: [member(ME)] }); + client.setQueryData(expenseKeys.members(ROOM_ID), []); + const members = watch(client, KEY, async () => ({ members: [member(ME), member(2)] })); dispatchRoomMemberEvent(client, ROOM_ID, joined(2)); - expect(client.getQueryData(KEY)?.members.map((m) => m.userId)).toEqual([1, 2]); + await expect.poll(() => members.fetches()).toBe(1); + await expect + .poll(() => client.getQueryData(KEY)?.members.map((m) => m.userId)) + .toEqual([ME, 2]); + expect(client.getQueryState(expenseKeys.members(ROOM_ID))?.isInvalidated).toBe(true); }); - it("keeps the pending refetch of a list invalidated while off screen", async () => { - const client = clientWith({ members: [member(1)] }); - await client.invalidateQueries({ queryKey: KEY, refetchType: "none" }); + it("이벤트 전에 출발한 조회가 늦게 와도 그 응답으로 덮지 않는다", async () => { + const client = newClient(); + client.setQueryData(KEY, { members: [member(ME)] }); + const responses: ((value: RoomMemberListResponse) => void)[] = []; + watch(client, KEY, () => new Promise((resolve) => responses.push(resolve))); + + // 이벤트 전 조회(2번 입장 전 상태를 받아 올 조회) + void client.refetchQueries({ queryKey: KEY }); + await expect.poll(() => responses.length).toBe(1); dispatchRoomMemberEvent(client, ROOM_ID, joined(2)); - dispatchRoomPresence(client, ROOM_ID, { - type: "USER_CONNECTED", - userId: 1, - roomId: ROOM_ID, - nickname: null, - profileImageUrl: null, - }); + await expect.poll(() => responses.length).toBe(2); + + responses[1]!({ members: [member(ME), member(2)] }); + responses[0]!({ members: [member(ME)] }); - expect(client.getQueryState(KEY)?.isInvalidated).toBe(true); - expect(client.getQueryData(KEY)).toEqual({ members: [member(1)] }); + await expect + .poll(() => client.getQueryData(KEY)?.members.map((m) => m.userId)) + .toEqual([ME, 2]); }); - it("does not build a partial list before the member list was ever loaded", () => { - const client = clientWith(); + it("목록을 받은 적이 없으면 한 명짜리 목록을 만들지 않는다", () => { + const client = newClient(); dispatchRoomMemberEvent(client, ROOM_ID, joined(2)); expect(client.getQueryData(KEY)).toBeUndefined(); }); + + it.each(["MEMBER_LEFT", "MEMBER_KICKED"] as const)( + "다른 사람의 %s는 다시 받는다", + (type) => { + const client = newClient(); + client.setQueryData(KEY, { members: [member(ME), member(2)] }); + + dispatchRoomMemberEvent(client, ROOM_ID, removed(type, 2)); + + expect(client.getQueryState(KEY)?.isInvalidated).toBe(true); + }, + ); + + it.each(["MEMBER_LEFT", "MEMBER_KICKED"] as const)( + "내 %s는 다시 받지 않는다 — 나간 방의 조회는 403이고, 방 캐시는 나가기·강퇴 처리가 지운다", + (type) => { + const client = newClient(); + client.setQueryData(KEY, { members: [member(ME), member(2)] }); + client.setQueryData(expenseKeys.members(ROOM_ID), []); + const members = watch(client, KEY, async () => ({ members: [] })); + + dispatchRoomMemberEvent(client, ROOM_ID, removed(type, ME)); + + expect(members.fetches()).toBe(0); + expect(client.getQueryState(KEY)?.isInvalidated).toBe(false); + expect(client.getQueryState(expenseKeys.members(ROOM_ID))?.isInvalidated).toBe(false); + }, + ); + + it("방장 위임을 받으면 다른 탭의 이전 방장도 방장이 아니게 된다(세 출처 모두 다시 받음)", async () => { + const client = newClient(); + // 이 탭은 이전 방장(나)이고, 위임은 다른 탭에서 했다 — 세 출처 모두 HOST가 남아 있다 + client.setQueryData(KEY, { members: [member(ME, "HOST"), member(2)] }); + client.setQueryData(roomDetailQueryKey(ROOM_ID), { + id: ROOM_ID, title: "여행", destinations: [], startDate: null, endDate: null, + inviteCode: "CODE", memberCount: 2, role: "HOST", createdAt: "", + }); + client.setQueryData(ROOMS_QUERY_KEY, { + rooms: [{ id: ROOM_ID, title: "여행", destinations: [], startDate: null, endDate: null, role: "HOST", joinedAt: "" }], + nextCursor: null, + hasNext: false, + }); + watch(client, KEY, async () => ({ members: [member(ME), member(2, "HOST")] })); + watch(client, roomDetailQueryKey(ROOM_ID), async () => ({ + ...client.getQueryData(roomDetailQueryKey(ROOM_ID))!, + role: "MEMBER", + })); + watch(client, ROOMS_QUERY_KEY, async () => { + const prev = client.getQueryData(ROOMS_QUERY_KEY)!; + return { ...prev, rooms: prev.rooms.map((r) => ({ ...r, role: "MEMBER" })) }; + }); + + dispatchRoomMemberEvent(client, ROOM_ID, delegated(ME, 2)); + + const viewerIsHost = () => + resolveViewerIsHost({ + listRole: client.getQueryData(ROOMS_QUERY_KEY)?.rooms[0]?.role, + detailRole: client.getQueryData(roomDetailQueryKey(ROOM_ID))?.role, + memberRole: client + .getQueryData(KEY) + ?.members.find((m) => m.userId === ME)?.role, + }); + await expect.poll(viewerIsHost).toBe(false); + }); }); diff --git a/src/lib/stomp/members-dispatch.tsx b/src/lib/stomp/members-dispatch.tsx index 572b4b1c..cff48035 100644 --- a/src/lib/stomp/members-dispatch.tsx +++ b/src/lib/stomp/members-dispatch.tsx @@ -4,72 +4,30 @@ import { expenseKeys } from "@/lib/expenses/expense-queries"; import type { QueryClient } from "@tanstack/react-query"; -import type { - RoomListResponse, - RoomMember, - RoomMemberListResponse, -} from "@/lib/api/rooms"; -import { CHAT_UNKNOWN_SENDER_LABEL } from "@/lib/chat"; -import { ROOMS_QUERY_KEY, roomMembersQueryKey } from "@/lib/query-keys"; +import { + ROOMS_QUERY_KEY, + roomDetailQueryKey, + roomMembersQueryKey, +} from "@/lib/query-keys"; import { readSessionUserId } from "@/lib/session-user-cache"; -import type { - MemberUserSnapshotDetail, - RoomMemberPayload, -} from "./member-events"; +import type { RoomMemberPayload } from "./member-events"; -function upsertMemberRow( - members: RoomMember[], - userId: number, - patch: Partial & Pick, - fallback: Omit, -): RoomMember[] { - const idx = members.findIndex((m) => m.userId === userId); - if (idx >= 0) { - const next = [...members]; - const cur = next[idx]!; - next[idx] = { ...cur, ...patch }; - return next; - } - return [...members, { userId, ...fallback, ...patch }]; -} - -function memberDisplayPatch( - d: MemberUserSnapshotDetail, -): Partial> { - const patch: Partial> = {}; - const nick = d.nickname?.trim(); - if (nick && nick.length > 0) patch.nickname = nick; - if (d.profileImageUrl !== undefined) patch.profileImageUrl = d.profileImageUrl; - return patch; -} - -function patchRoomsRoleForHostDelegation( - queryClient: QueryClient, - roomId: string, - previousHostUserId: number, - newHostUserId: number, -): void { - const me = readSessionUserId(queryClient); - if (me == null) return; - - queryClient.setQueryData(ROOMS_QUERY_KEY, (prev) => { - if (!prev?.rooms?.length) return prev; - return { - ...prev, - rooms: prev.rooms.map((r) => { - if (r.id !== roomId) return r; - if (me === previousHostUserId) return { ...r, role: "MEMBER" }; - if (me === newHostUserId) return { ...r, role: "HOST" }; - return r; - }), - }; - }); +/** + * 내가 이 방에서 나가거나 내보내진 이벤트인지 — 이미 접근을 잃은 방이라 다시 받으면 403을 받는다. + * 방 캐시는 나가기(`useLeaveRoom`·다른 탭은 `onSelfLeftRoom`)와 강퇴(개인 큐) 처리가 지운다. + * 누가 나갔는지 모르는 이벤트도 나일 수 있어 같이 건너뛴다. + */ +function removesViewer(queryClient: QueryClient, event: RoomMemberPayload): boolean { + if (event.type !== "MEMBER_LEFT" && event.type !== "MEMBER_KICKED") return false; + return event.detail == null || event.detail.userId === readSessionUserId(queryClient); } /** - * members STOMP 한 건 — `metadata`만으로 `room-members`·방 목록(role) 캐시를 직접 갱신합니다. - * 채팅 UI는 room-members 캐시의 userId와 메시지 senderId 매핑으로 발신자를 표시합니다. + * members STOMP 한 건 — 바뀐 멤버 목록을 서버에서 다시 받게 한다. 이벤트 내용으로 캐시를 고치지 않는다: + * 이벤트 전에 출발한 조회가 늦게 와도 TanStack이 그 조회를 취소하고 버린다(패치는 그 응답에 덮였다). + * 화면에 있는 목록만 바로 다시 받고 나머지는 다시 볼 때 받는다. + * 채팅 발신자 이름은 `room-members`에서 찾으므로, 새 멤버의 이름은 다시 받을 때까지 '(알 수 없음)'으로 보인다. */ export function dispatchRoomMemberEvent( queryClient: QueryClient, @@ -77,129 +35,15 @@ export function dispatchRoomMemberEvent( event: RoomMemberPayload, ): void { const rid = String(event.roomId ?? "").trim() || subscribedRoomId; - if (!rid) return; - void queryClient.invalidateQueries({ queryKey: expenseKeys.members(rid) }); + if (!rid || removesViewer(queryClient, event)) return; - // 무효화돼 다시 받을 목록은 패치하지 않는다 — setQueryData가 '갱신 필요' 표시를 지워 재조회가 사라진다. - // 방 목록 역할(HOST_DELEGATED)은 따로 맞춘다 - const membersAwaitingRefetch = - queryClient.getQueryState(roomMembersQueryKey(rid))?.isInvalidated === true; - - switch (event.type) { - case "MEMBER_JOINED": { - const d = event.detail; - if (!d || membersAwaitingRefetch) return; - - queryClient.setQueryData( - roomMembersQueryKey(rid), - (prev) => { - // 목록을 받은 적이 없으면 한 명짜리 목록을 만들지 않고 처음 조회에 맡긴다 - if (!prev) return prev; - const members = prev.members; - const joinedAt = - event.createdAt.trim().length > 0 - ? event.createdAt - : new Date().toISOString(); - const display = memberDisplayPatch(d); - const next = upsertMemberRow( - members, - d.userId, - { - ...display, - status: "ACTIVE", - isOnline: true, - }, - { - nickname: display.nickname ?? CHAT_UNKNOWN_SENDER_LABEL, - profileImageUrl: display.profileImageUrl ?? null, - role: "MEMBER", - status: "ACTIVE", - joinedAt, - isOnline: true, - }, - ); - return { ...prev, members: next }; - }, - ); - break; - } - - case "MEMBER_LEFT": - case "MEMBER_KICKED": { - const d = event.detail; - if (!d || membersAwaitingRefetch) return; - - queryClient.setQueryData( - roomMembersQueryKey(rid), - (prev) => { - if (!prev) return prev; - const members = prev.members; - const idx = members.findIndex((m) => m.userId === d.userId); - const display = memberDisplayPatch(d); - - if (idx >= 0) { - const next = [...members]; - const cur = next[idx]!; - next[idx] = { - ...cur, - ...display, - status: "LEFT", - isOnline: false, - }; - return { ...prev, members: next }; - } - - const leftMember: RoomMember = { - userId: d.userId, - nickname: display.nickname ?? CHAT_UNKNOWN_SENDER_LABEL, - profileImageUrl: display.profileImageUrl ?? null, - role: "MEMBER", - status: "LEFT", - joinedAt: - event.createdAt.trim().length > 0 - ? event.createdAt - : new Date().toISOString(), - isOnline: false, - }; - return { ...prev, members: [...members, leftMember] }; - }, - ); - break; - } - - case "HOST_DELEGATED": { - const d = event.detail; - if (!d) return; - - if (!membersAwaitingRefetch) { - queryClient.setQueryData( - roomMembersQueryKey(rid), - (prev) => { - if (!prev?.members?.length) return prev; - const members = prev.members.map((m) => { - if (m.userId === d.previousHostUserId) { - return { ...m, role: "MEMBER" as const }; - } - if (m.userId === d.newHostUserId) { - return { ...m, role: "HOST" as const }; - } - return m; - }); - return { ...prev, members }; - }, - ); - } - - patchRoomsRoleForHostDelegation( - queryClient, - rid, - d.previousHostUserId, - d.newHostUserId, - ); - break; - } + void queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(rid) }); + void queryClient.invalidateQueries({ queryKey: expenseKeys.members(rid) }); - default: - break; + if (event.type === "HOST_DELEGATED") { + // 방장 판정은 방 목록·방 상세·멤버 세 출처 중 하나라도 HOST면 방장으로 본다(resolveViewerIsHost). + // 하나라도 옛 역할이 남으면 이전 방장에게 "먼저 방장을 위임해 주세요"가 다시 뜬다 + void queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: roomDetailQueryKey(rid) }); } } From 359c1996085cd4badc095eb3e923abf981f41c0a Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:09:29 +0900 Subject: [PATCH 09/27] =?UTF-8?q?fix(presence):=20=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EC=A4=91=EC=9D=B8=20=EB=A9=A4=EB=B2=84=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=91=EC=86=8D=20=EC=83=81=ED=83=9C=20=ED=8C=A8?= =?UTF-8?q?=EC=B9=98=EB=A5=BC=20=EB=8D=AE=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 접속·해제는 잦아 isOnline 패치를 예외로 남긴다. 사유와 범위(ACTIVE 멤버 isOnline만)를 코드 주석에 적는다 - 멤버 목록을 받는 중이면 그 조회는 접속 변화 전에 출발했을 수 있어, 패치 대신 무효화로 옛 조회를 취소하고 다시 받는다 - 바뀌는 것이 없으면 캐시를 쓰지 않는다 - presence-dispatch.test.ts 추가 Co-Authored-By: Claude Opus 5.5 --- src/lib/stomp/presence-dispatch.test.ts | 121 ++++++++++++++++++++++++ src/lib/stomp/presence-dispatch.tsx | 52 +++++----- 2 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 src/lib/stomp/presence-dispatch.test.ts diff --git a/src/lib/stomp/presence-dispatch.test.ts b/src/lib/stomp/presence-dispatch.test.ts new file mode 100644 index 00000000..b68bfeb9 --- /dev/null +++ b/src/lib/stomp/presence-dispatch.test.ts @@ -0,0 +1,121 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { RoomMember, RoomMemberListResponse } from "@/lib/api/rooms"; +import { roomMembersQueryKey, sessionUserQueryKey } from "@/lib/query-keys"; +import type { RoomPresenceChangedEvent } from "@/lib/stomp/events"; + +import { dispatchRoomPresence, optimisticPatchSelfOnline } from "./presence-dispatch"; + +const ROOM_ID = "room"; +const KEY = roomMembersQueryKey(ROOM_ID); + +const member = (userId: number, patch: Partial = {}): RoomMember => ({ + userId, + nickname: `user-${userId}`, + profileImageUrl: null, + role: "MEMBER", + status: "ACTIVE", + joinedAt: "2026-10-25T00:00:00Z", + isOnline: false, + ...patch, +}); + +const connected = (userId: number): RoomPresenceChangedEvent => ({ + type: "USER_CONNECTED", + userId, + roomId: ROOM_ID, + nickname: null, + profileImageUrl: null, +}); + +const onlineOf = (client: QueryClient) => + client.getQueryData(KEY)?.members.map((m) => [m.userId, m.isOnline]); + +const clients: QueryClient[] = []; +const stops: (() => void)[] = []; + +function newClient(data?: RoomMemberListResponse) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + if (data) client.setQueryData(KEY, data); + return client; +} + +afterEach(() => { + for (const stop of stops.splice(0)) stop(); + for (const client of clients.splice(0)) client.clear(); +}); + +describe("dispatchRoomPresence", () => { + it("접속한 멤버의 isOnline만 GET 없이 고친다", () => { + const client = newClient({ members: [member(1), member(2)] }); + + dispatchRoomPresence(client, ROOM_ID, connected(2)); + + expect(onlineOf(client)).toEqual([[1, false], [2, true]]); + expect(client.getQueryState(KEY)?.isInvalidated).toBe(false); + }); + + it("나간 멤버와 목록에 없는 사용자는 고치지 않는다", () => { + const client = newClient({ members: [member(1, { status: "LEFT" })] }); + const before = client.getQueryData(KEY); + + dispatchRoomPresence(client, ROOM_ID, connected(1)); + dispatchRoomPresence(client, ROOM_ID, connected(3)); + + expect(client.getQueryData(KEY)).toBe(before); + }); + + it("목록을 받은 적이 없으면 만들지 않는다", () => { + const client = newClient(); + + dispatchRoomPresence(client, ROOM_ID, connected(1)); + + expect(client.getQueryData(KEY)).toBeUndefined(); + }); + + it("다시 받을 표시가 남은 목록은 고치지 않아 재조회가 사라지지 않는다", async () => { + const client = newClient({ members: [member(1)] }); + await client.invalidateQueries({ queryKey: KEY, refetchType: "none" }); + + dispatchRoomPresence(client, ROOM_ID, connected(1)); + + expect(client.getQueryState(KEY)?.isInvalidated).toBe(true); + expect(onlineOf(client)).toEqual([[1, false]]); + }); + + it("받는 중인 목록이 있으면 고치지 않고 다시 받아, 접속 전에 출발한 응답이 접속 상태를 덮지 않는다", async () => { + const client = newClient({ members: [member(1), member(2)] }); + const responses: ((value: RoomMemberListResponse) => void)[] = []; + const observer = new QueryObserver(client, { + queryKey: KEY, + queryFn: () => new Promise((resolve) => responses.push(resolve)), + staleTime: Infinity, + }); + stops.push(observer.subscribe(() => {})); + + // 2번이 접속하기 전에 출발한 조회 + void observer.refetch(); + await expect.poll(() => responses.length).toBe(1); + + dispatchRoomPresence(client, ROOM_ID, connected(2)); + await expect.poll(() => responses.length).toBe(2); + + responses[1]!({ members: [member(1), member(2, { isOnline: true })] }); + responses[0]!({ members: [member(1), member(2)] }); + + await expect.poll(() => onlineOf(client)).toEqual([[1, false], [2, true]]); + }); +}); + +describe("optimisticPatchSelfOnline", () => { + it("구독 직후 본인을 접속 중으로 표시한다", () => { + const client = newClient({ members: [member(1), member(2)] }); + client.setQueryData(sessionUserQueryKey, { id: 1, nickname: "나", profileImageUrl: null }); + + optimisticPatchSelfOnline(client, ROOM_ID); + + expect(onlineOf(client)).toEqual([[1, true], [2, false]]); + }); +}); diff --git a/src/lib/stomp/presence-dispatch.tsx b/src/lib/stomp/presence-dispatch.tsx index 0c8f85d8..e702b208 100644 --- a/src/lib/stomp/presence-dispatch.tsx +++ b/src/lib/stomp/presence-dispatch.tsx @@ -12,42 +12,44 @@ const PRESENCE_MEMBERS_RESYNC_DELAY_MS = 400; const roomMembersResyncTimers = new Map>(); +/** + * 접속 상태(`isOnline`) 한 명분만 캐시에서 고친다. AGENTS.md 예외: 접속·해제는 멤버 변경보다 훨씬 잦아 + * 이벤트마다 멤버 목록 GET을 보내지 않는다. 범위는 `room-members`의 ACTIVE 멤버 `isOnline`뿐이다. + * 받는 중인 목록은 이 접속 변화 전에 출발한 조회일 수 있어 늦게 와서 패치를 덮으므로, 그때는 고치지 않고 다시 받는다. + */ function patchMemberOnline( queryClient: QueryClient, roomId: string, userId: number, isOnline: boolean, -): boolean { +): void { const rid = roomId.trim(); - if (!rid.length) return false; + if (!rid.length) return; - // 무효화돼 다시 받을 목록은 패치하지 않는다 — setQueryData가 '갱신 필요' 표시를 지워 재조회가 사라진다 - if (queryClient.getQueryState(roomMembersQueryKey(rid))?.isInvalidated) { - return false; + const queryKey = roomMembersQueryKey(rid); + const state = queryClient.getQueryState(queryKey); + // 옛 조회를 취소하고 새로 받는다. 목록이 아직 없는 첫 조회는 TanStack이 취소하지 않고 합류하며, 구독 직후 재동기화가 맞춘다 + if (state?.fetchStatus === "fetching") { + void queryClient.invalidateQueries({ queryKey, exact: true }); + return; } - - const cached = queryClient.getQueryData( - roomMembersQueryKey(rid), - ); - if (!cached?.members?.length) return false; - - let touched = false; - const members = cached.members.map((m) => { - if (m.userId !== userId) return m; - if (m.status !== "ACTIVE") return m; - touched = true; - return { ...m, isOnline }; - }); - if (!touched) return false; - - queryClient.setQueryData(roomMembersQueryKey(rid), { - ...cached, - members, + // 무효화돼 다시 받을 목록은 패치하지 않는다 — setQueryData가 '갱신 필요' 표시를 지워 재조회가 사라진다 + if (state?.isInvalidated) return; + + queryClient.setQueryData(queryKey, (prev) => { + // 목록을 받은 적이 없으면 만들지 않고 처음 조회에 맡긴다 + if (!prev) return prev; + const target = prev.members.find((m) => m.userId === userId); + // 바뀌는 것이 없으면 undefined를 돌려 쓰지 않는다 + if (target?.status !== "ACTIVE" || target.isOnline === isOnline) return undefined; + return { + ...prev, + members: prev.members.map((m) => (m === target ? { ...m, isOnline } : m)), + }; }); - return true; } -/** presence STOMP — 해당 유저 `isOnline`만 캐시 패치 (GET 없음). */ +/** presence STOMP — 해당 유저 `isOnline`만 캐시 패치. 멤버 목록을 받는 중이면 패치 대신 다시 받는다 */ export function dispatchRoomPresence( queryClient: QueryClient, roomId: string, From ce09a9c5f5cdf3f44611b585f05be9fb73758b9f Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:09:32 +0900 Subject: [PATCH 10/27] =?UTF-8?q?chore(presence):=20=EC=93=B0=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20syncRoomMembersIfCacheEmpty=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deprecated 표시 뒤 호출부가 없다. 구독 직후 재동기화는 scheduleRoomMembersResyncAfterSubscribe가 맡는다 Co-Authored-By: Claude Opus 5.5 --- src/lib/stomp/presence-dispatch.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/lib/stomp/presence-dispatch.tsx b/src/lib/stomp/presence-dispatch.tsx index e702b208..1008f162 100644 --- a/src/lib/stomp/presence-dispatch.tsx +++ b/src/lib/stomp/presence-dispatch.tsx @@ -115,19 +115,3 @@ export function scheduleRoomMembersResyncAfterSubscribe( }, PRESENCE_MEMBERS_RESYNC_DELAY_MS), ); } - -/** @deprecated 구독 직후 `scheduleRoomMembersResyncAfterSubscribe` 사용 */ -export async function syncRoomMembersIfCacheEmpty( - queryClient: QueryClient, - roomId: string, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - - const cached = queryClient.getQueryData( - roomMembersQueryKey(rid), - ); - if (cached?.members?.length) return; - - await invalidateRoomMembersActive(queryClient, rid); -} From 72cc594de8d3cd61bdf97353ef83ba51005d3eef Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:10:22 +0900 Subject: [PATCH 11/27] =?UTF-8?q?refactor(members):=20=EB=A9=A4=EB=B2=84?= =?UTF-8?q?=C2=B7=EC=9E=85=EC=9E=A5=20=EC=9A=94=EC=B2=AD=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=92=A4=20onSettled=EC=97=90=EC=84=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 방장 위임: 응답 없이 확정된 결과를 onSuccess에서 세 출처에 반영하고, onSettled(실패 포함)에서 세 출처를 무효화한다. 화면은 기다리지 않는다(void) - 강퇴·입장 승인·거절: 응답 본문이 없어 onSettled 무효화를 반환해 목록에서 빠질 때까지 진행 표시를 유지한다 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useRooms.ts | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 41ef5622..b8a682c9 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -659,10 +659,12 @@ export function useTransferHost() { readSessionUserId(queryClient), targetUserId, ); - // 반영한 결과를 서버 값으로 다시 맞춘다 — 캐시는 바로 바뀌어 있으므로 화면은 기다리지 않는다 - queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(roomId) }); - queryClient.invalidateQueries({ queryKey: roomDetailQueryKey(roomId) }); - queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }); + }, + // 방장 판정의 세 출처를 서버 값으로 다시 맞춘다(실패해도) — 성공하면 캐시는 바로 바뀌어 있으므로 화면은 기다리지 않는다 + onSettled: (_data, _error, { roomId }) => { + void queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(roomId) }); + void queryClient.invalidateQueries({ queryKey: roomDetailQueryKey(roomId) }); + void queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }); }, }); } @@ -682,10 +684,12 @@ export function useKickMember() { return useMutation({ mutationFn: ({ roomId, userId }: { roomId: string; userId: number }) => kickMember(roomId, userId), - onSuccess: (_, { roomId }) => { - queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(roomId) }); - queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }); - }, + // 응답 본문이 없어 목록에서 빠질 때까지 기다린다 — 확인창이 닫힌 뒤 내보낸 멤버가 잠깐 남지 않게 + onSettled: (_data, _error, { roomId }) => + Promise.all([ + queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(roomId) }), + queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }), + ]), }); } @@ -699,11 +703,13 @@ export function useApproveJoinRequest() { roomId: string; requestId: number; }) => approveJoinRequest(roomId, requestId), - onSuccess: (_, { roomId }) => { - queryClient.invalidateQueries({ queryKey: joinRequestsQueryKey(roomId) }); - queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(roomId) }); - queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }); - }, + // 응답 본문이 없어 요청 목록에서 빠지고 멤버 목록에 들어올 때까지 기다린다(같은 행을 다시 누르지 않게) + onSettled: (_data, _error, { roomId }) => + Promise.all([ + queryClient.invalidateQueries({ queryKey: joinRequestsQueryKey(roomId) }), + queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(roomId) }), + queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }), + ]), }); } @@ -717,9 +723,9 @@ export function useRejectJoinRequest() { roomId: string; requestId: number; }) => rejectJoinRequest(roomId, requestId), - onSuccess: (_, { roomId }) => { - queryClient.invalidateQueries({ queryKey: joinRequestsQueryKey(roomId) }); - }, + // 응답 본문이 없어 요청 목록에서 빠질 때까지 기다린다(같은 행을 다시 누르지 않게) + onSettled: (_data, _error, { roomId }) => + queryClient.invalidateQueries({ queryKey: joinRequestsQueryKey(roomId) }), }); } From b28b6d9d4b9a41b8fd50fdc71c3a2cb72d2ec20a Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:11:02 +0900 Subject: [PATCH 12/27] =?UTF-8?q?fix(members):=20=EB=B0=A9=EC=9D=84=20?= =?UTF-8?q?=EB=82=98=EA=B0=80=EB=A9=B4=20=EB=82=98=EA=B0=84=20=EB=B0=A9=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=EB=A5=BC=20=EB=8B=A4=EC=8B=9C=20=EB=B0=9B?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=B0=94=EB=A1=9C=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 나간 방은 접근 권한을 잃어 방 범위 조회가 403을 받는다. onSuccess에서 evictRoomFromClientCaches로 그 방 캐시·가계부 접근을 지운다(AGENTS.md: 권한 상실 시 즉시 제거) - onSettled(실패 포함)에서는 방 목록(ROOMS)만 무효화한다. 나간 방은 이미 목록에서 빠져 기다리지 않는다 - 다른 탭은 STOMP 본인 MEMBER_LEFT(onSelfLeftRoom)가 같은 정리를 한다 - useLeaveRoom.test.tsx 추가 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useLeaveRoom.test.tsx | 54 +++++++++++++++++++++++++++++++++ src/hooks/useRooms.ts | 11 +++++-- 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useLeaveRoom.test.tsx diff --git a/src/hooks/useLeaveRoom.test.tsx b/src/hooks/useLeaveRoom.test.tsx new file mode 100644 index 00000000..98be34ad --- /dev/null +++ b/src/hooks/useLeaveRoom.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { expect, it, vi } from "vitest"; + +import type { RoomListResponse } from "@/lib/api/rooms"; +import { ROOMS_QUERY_KEY, roomMembersQueryKey } from "@/lib/query-keys"; + +import { useLeaveRoom } from "./useRooms"; + +const api = vi.hoisted(() => ({ leave: vi.fn(), getRooms: vi.fn() })); +vi.mock("@/lib/api/rooms", async (importOriginal) => ({ + ...(await importOriginal()), + leaveRoom: api.leave, + getRooms: api.getRooms, +})); + +const rooms = (ids: string[]): RoomListResponse => ({ + rooms: ids.map((id) => ({ id, title: id, destinations: [], startDate: null, endDate: null, role: "MEMBER", joinedAt: "" })), + nextCursor: null, + hasNext: false, +}); + +it("방을 나가면 그 방 캐시는 다시 받지 않고 지우고, 방 목록만 다시 받도록 표시한다", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + api.leave.mockResolvedValue(undefined); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(ROOMS_QUERY_KEY, rooms(["left", "other"])); + client.setQueryData(roomMembersQueryKey("left"), { members: [] }); + client.setQueryData(roomMembersQueryKey("other"), { members: [] }); + + let leave: ReturnType["mutateAsync"] | undefined; + const capture = (value: typeof leave) => { leave = value; }; + function Probe({ onRender }: { onRender: (value: typeof leave) => void }) { + onRender(useLeaveRoom().mutateAsync); + return null; + } + const root = createRoot(document.createElement("div")); + try { + await act(async () => root.render()); + await act(async () => { await leave!("left"); }); + + // 나간 방의 GET은 403이라 무효화하지 않고 지운다 + expect(client.getQueryState(roomMembersQueryKey("left"))).toBeUndefined(); + expect(client.getQueryState(roomMembersQueryKey("other"))?.isInvalidated).toBe(false); + expect(client.getQueryData(ROOMS_QUERY_KEY)?.rooms.map((r) => r.id)).toEqual(["other"]); + expect(client.getQueryState(ROOMS_QUERY_KEY)?.isInvalidated).toBe(true); + } finally { + await act(async () => root.unmount()); + client.clear(); + vi.unstubAllGlobals(); + } +}); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index b8a682c9..b92afbce 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -98,6 +98,7 @@ import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epo import { useSessionUser } from "@/hooks/useSessionUser"; import { applyHostTransferToCache } from "@/lib/rooms/host-transfer-cache"; import { readSessionUserId } from "@/lib/session-user-cache"; +import { evictRoomFromClientCaches } from "@/lib/stomp/forced-room-exit-dispatch"; export function useRoomsList() { return useQuery({ @@ -673,8 +674,14 @@ export function useLeaveRoom() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (roomId: string) => leaveRoom(roomId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }); + // 나간 방은 접근 권한을 잃어 방 범위 조회가 403을 받는다 — 다시 받지 않고 바로 지운다(AGENTS.md: 권한 상실 시 즉시 제거). + // 다른 탭은 STOMP 본인 MEMBER_LEFT(onSelfLeftRoom)가 같은 정리를 한다 + onSuccess: (_, roomId) => { + evictRoomFromClientCaches(queryClient, roomId); + }, + // 방 목록만 서버 기준으로 맞춘다. 나간 방은 위에서 이미 목록에서 뺐으므로 기다리지 않는다 + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }); }, }); } From 798389d82db66619165620466273cbf176dbbddb Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 8 Nov 2026 16:13:14 +0900 Subject: [PATCH 13/27] =?UTF-8?q?perf(members):=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EA=B0=80=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20signal=EC=9D=84=20=EB=B0=9B=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRoomMembers queryFn이 signal을 getRoomMembers에 넘긴다 - 멤버 이벤트·접속 변화로 받는 중인 조회를 TanStack이 취소하면 그 HTTP 요청도 끊긴다(연달아 오는 이벤트에 옛 요청이 끝까지 가지 않게) Co-Authored-By: Claude Opus 5.5 --- src/hooks/useRooms.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index b92afbce..9d721a25 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -594,7 +594,8 @@ export function useRoomMembers(roomId: string | null) { return useQuery({ queryKey: roomMembersQueryKey(roomId), - queryFn: () => getRoomMembers(roomId!), + // 멤버 이벤트·접속 변화로 받는 중인 조회가 취소되면 그 HTTP 요청도 끊는다 + queryFn: ({ signal }) => getRoomMembers(roomId!, signal), enabled: !!roomId && (!waitForStompBeforeMembers || stompConnected), staleTime: Infinity, refetchOnWindowFocus: false, From 25c8c81cee2ef4ef091c05be219faff47f2eb23c Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:18:54 +0900 Subject: [PATCH 14/27] =?UTF-8?q?refactor(expenses):=20=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EB=B6=80=20=EC=A1=B0=ED=9A=8C=EA=B0=80=20TanStack=EC=9D=98=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20signal=EC=9D=84=20=EB=B0=9B=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가계부 조회 함수(목록·요약·환산 요약·예산·통화)와 getRoomMembers가 선택 인자 signal을 받아 fetch에 넘긴다 - ExpenseProvider의 조회·readLatest·readLatestBudget queryFn이 signal을 넘겨, TanStack이 옛 조회를 취소하면 HTTP 요청도 끊긴다 - signal이 없으면 지금처럼 init 없이 보낸다 - ExpenseProvider.test: 환산 요약 호출 인자에 signal을 반영, 방 전체 합계 판별을 인자 개수 대신 필터 여부로 바꾼다 Co-Authored-By: Claude Opus 5.5 --- .../expenses/ExpenseProvider.test.tsx | 8 +++--- src/components/expenses/ExpenseProvider.tsx | 18 ++++++------- src/lib/api/rooms/expenses.test.ts | 20 ++++++++++++++ src/lib/api/rooms/expenses.ts | 26 ++++++++++++------- src/lib/api/rooms/members.ts | 3 ++- 5 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx index 1e8b7ed1..884a454f 100644 --- a/src/components/expenses/ExpenseProvider.test.tsx +++ b/src/components/expenses/ExpenseProvider.test.tsx @@ -899,13 +899,13 @@ it("isolates AND-filtered KRW snapshots and refreshes them on expense invalidati const filters = { expenseGroup: "TRIP_DAY" as const, scheduleId: 10, category: "FOOD" as const }; await act(async () => context.setKrwFilters(filters)); await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - expect(mocks.krw).toHaveBeenCalledWith("r", filters); + expect(mocks.krw).toHaveBeenCalledWith("r", filters, expect.any(AbortSignal)); expect(client.getQueryData(expenseKeys.krwSummary("r"))).toBeDefined(); expect(client.getQueryData([...expenseKeys.krwSummary("r"), filters])).toBeDefined(); mocks.krw.mockClear(); const { getExpenseRecovery } = await import("@/lib/expenses/expense-recovery"); await act(async () => getExpenseRecovery(client, "r").message(JSON.stringify({ roomId: "r", type: "EXPENSES_INVALIDATED" }))); - expect(mocks.krw).toHaveBeenCalledWith("r", filters); + expect(mocks.krw).toHaveBeenCalledWith("r", filters, expect.any(AbortSignal)); expect(mocks.krw).toHaveBeenCalledWith("r"); }); @@ -937,7 +937,9 @@ it("returns to all expenses without refetching or blanking the whole-room summar const whole = context.krwSummary.data!; const previous = mocks.krw.getMockImplementation()!; let finish: ((value: ExpenseKrwSummary) => void) | undefined; - mocks.krw.mockImplementation((...args) => args.length === 1 ? new Promise(resolve => { finish = resolve; }) : previous(...args)); + // 필터 없는 방 전체 합계 조회만 붙잡는다(필터 인자가 없거나 빈 객체) + const wholeRoom = (args: unknown[]) => Object.keys((args[1] as object | undefined) ?? {}).length === 0; + mocks.krw.mockImplementation((...args) => wholeRoom(args) ? new Promise(resolve => { finish = resolve; }) : previous(...args)); try { await act(async () => context.setKrwFilters({})); await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx index 7052cd46..06b3a741 100644 --- a/src/components/expenses/ExpenseProvider.tsx +++ b/src/components/expenses/ExpenseProvider.tsx @@ -99,7 +99,7 @@ function useExpenses(roomId: string) { // The shared presence cache may contain only a partial STOMP member snapshot. const memberQuery = useQuery({ queryKey: expenseKeys.members(roomId), - queryFn: () => getRoomMembers(roomId), + queryFn: ({ signal }) => getRoomMembers(roomId, signal), enabled, retry: false, staleTime: 0, @@ -122,22 +122,22 @@ function useExpenses(roomId: string) { const list = useQuery({ ...options, queryKey: expenseKeys.list(roomId), - queryFn: () => getExpenses(roomId), + queryFn: ({ signal }) => getExpenses(roomId, signal), }); const summary = useQuery({ ...options, queryKey: expenseKeys.summary(roomId), - queryFn: () => getExpenseSummary(roomId), + queryFn: ({ signal }) => getExpenseSummary(roomId, signal), }); const budget = useQuery({ ...options, queryKey: expenseKeys.budget(roomId), - queryFn: () => getExpenseBudget(roomId), + queryFn: ({ signal }) => getExpenseBudget(roomId, signal), }); const krwSummary = useQuery({ ...options, queryKey: expenseKeys.krwSummary(roomId), - queryFn: () => getExpenseKrwSummary(roomId), + queryFn: ({ signal }) => getExpenseKrwSummary(roomId, {}, signal), }); const [krwFilters, setKrwFilters] = useState({}); const filteredKrwSummary = useQuery({ @@ -146,7 +146,7 @@ function useExpenses(roomId: string) { // onto that stale query when returning to All and trigger a whole-room refetch. enabled: enabled && Object.keys(krwFilters).length > 0, queryKey: [...expenseKeys.krwSummary(roomId), krwFilters], - queryFn: () => getExpenseKrwSummary(roomId, krwFilters), + queryFn: ({ signal }) => getExpenseKrwSummary(roomId, krwFilters, signal), }); const budgetMutation = useMutation({ mutationFn: async (body: ExpenseBudgetInput) => { @@ -196,7 +196,7 @@ function useExpenses(roomId: string) { const currencies = useQuery({ ...options, queryKey: expenseKeys.currencies(roomId), - queryFn: () => getExpenseCurrencies(roomId), + queryFn: ({ signal }) => getExpenseCurrencies(roomId, signal), }); const mutation = useMutation({ mutationFn: async ( @@ -320,7 +320,7 @@ function useExpenses(roomId: string) { throw new Error("방 접근 권한이 없어요."); return client.fetchQuery({ queryKey: expenseKeys.budget(roomId), - queryFn: () => getExpenseBudget(roomId), + queryFn: ({ signal }) => getExpenseBudget(roomId, signal), staleTime: 0, retry: false, }); @@ -341,7 +341,7 @@ function useExpenses(roomId: string) { throw new Error("방 접근 권한이 없어요."); const records = await client.fetchQuery({ queryKey: expenseKeys.list(roomId), - queryFn: () => getExpenses(roomId), + queryFn: ({ signal }) => getExpenses(roomId, signal), staleTime: 0, retry: false, }); diff --git a/src/lib/api/rooms/expenses.test.ts b/src/lib/api/rooms/expenses.test.ts index 3bb79537..e406c09c 100644 --- a/src/lib/api/rooms/expenses.test.ts +++ b/src/lib/api/rooms/expenses.test.ts @@ -258,3 +258,23 @@ it("sends AND filters without changing the returned whole-room and filtered tota expect(await getExpenseKrwSummary("r", { expenseGroup: "TRIP_DAY", scheduleId: 10, category: "FOOD", payerUserId: 2 })).toEqual(response); expect(fetcher.mock.lastCall![0]).toBe("http://fixture/rooms/r/expenses/summary/krw?expenseGroup=TRIP_DAY&scheduleId=10&category=FOOD&payerUserId=2"); }); + +it("passes the query cancel signal to read requests so a superseded read is aborted", async () => { + const api = await import("./expenses"); + const controller = new AbortController(); + for (const read of [ + () => api.getExpenses("r", controller.signal), + () => api.getExpenseSummary("r", controller.signal), + () => api.getExpenseCurrencies("r", controller.signal), + () => api.getExpenseBudget("r", controller.signal), + () => api.getExpenseKrwSummary("r", { category: "FOOD" }, controller.signal), + ]) { + fetcher.mockResolvedValueOnce(new Response(JSON.stringify([]))); + await read(); + expect(fetcher.mock.lastCall?.[1]).toEqual({ signal: controller.signal }); + } + // signal 없이 부르면 지금처럼 init 없이 보낸다 + fetcher.mockResolvedValueOnce(new Response(JSON.stringify([]))); + await api.getExpenses("r"); + expect(fetcher.mock.lastCall?.[1]).toBeUndefined(); +}); diff --git a/src/lib/api/rooms/expenses.ts b/src/lib/api/rooms/expenses.ts index 9ca47747..d91583a2 100644 --- a/src/lib/api/rooms/expenses.ts +++ b/src/lib/api/rooms/expenses.ts @@ -96,11 +96,15 @@ async function request( } return res.status === 204 ? (undefined as T) : (res.json() as Promise); } -export const getExpenses = (roomId: string) => request(roomId); -export const getExpenseSummary = (roomId: string) => - request(roomId, "/summary"); -export const getExpenseCurrencies = (roomId: string) => - request(roomId, "/currencies"); +/** 조회 함수는 TanStack이 넘기는 `signal`을 받는다 — 무효화로 취소된 옛 조회의 HTTP 요청도 끊는다 */ +const readInit = (signal?: AbortSignal): RequestInit | undefined => + signal ? { signal } : undefined; +export const getExpenses = (roomId: string, signal?: AbortSignal) => + request(roomId, "", readInit(signal)); +export const getExpenseSummary = (roomId: string, signal?: AbortSignal) => + request(roomId, "/summary", readInit(signal)); +export const getExpenseCurrencies = (roomId: string, signal?: AbortSignal) => + request(roomId, "/currencies", readInit(signal)); export const createExpense = (roomId: string, body: ExpenseInput) => request(roomId, "", { method: "POST", ...jsonBody(body) }); export const patchExpense = ( @@ -149,19 +153,23 @@ export type ExpenseKrwFilters = { category?: ExpenseCategory; payerUserId?: number; }; -export const getExpenseBudget = (roomId: string) => - request(roomId, "/budget"); +export const getExpenseBudget = (roomId: string, signal?: AbortSignal) => + request(roomId, "/budget", readInit(signal)); export const putExpenseBudget = (roomId: string, body: ExpenseBudgetInput) => request(roomId, "/budget", { method: "PUT", ...jsonBody(body), }); -export const getExpenseKrwSummary = (roomId: string, filters: ExpenseKrwFilters = {}) => { +export const getExpenseKrwSummary = ( + roomId: string, + filters: ExpenseKrwFilters = {}, + signal?: AbortSignal, +) => { const query = new URLSearchParams(); for (const key of ["expenseGroup", "scheduleId", "category", "payerUserId"] as const) { const value = filters[key]; if (value !== undefined) query.set(key, String(value)); } const suffix = query.size ? "?" + query.toString() : ""; - return request(roomId, `/summary/krw${suffix}`); + return request(roomId, `/summary/krw${suffix}`, readInit(signal)); }; diff --git a/src/lib/api/rooms/members.ts b/src/lib/api/rooms/members.ts index 16714217..6f1587d4 100644 --- a/src/lib/api/rooms/members.ts +++ b/src/lib/api/rooms/members.ts @@ -38,10 +38,11 @@ function normalizeMemberListPayload( export async function getRoomMembers( roomId: string, + signal?: AbortSignal, ): Promise { const res = await requestJson( apiUrl(`/rooms/${roomId}/members`), - undefined, + signal ? { signal } : undefined, { errorMessage: "멤버 목록 조회 실패" }, ); return normalizeMemberListPayload(res); From 32c6e7b36c2b2960c4624b388ad3ba05fbf8920e Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:22:01 +0900 Subject: [PATCH 15/27] =?UTF-8?q?refactor(expenses):=20STOMP=C2=B7?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EB=AC=B4?= =?UTF-8?q?=ED=9A=A8=ED=99=94=EB=A1=9C=20=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 복구가 키마다 cancelQueries 뒤 자체 queryFn으로 fetchQuery하던 것을 invalidateQueries(기본 refetchType active)로 바꾼다. 다시 받는 일은 화면의 ExpenseProvider 조회가 자기 queryFn으로 한다 - 진행 중 조회는 TanStack 기본값대로 취소하고 새로 받는다(cancelRefetch: false 합류 없음). 취소된 HTTP 요청은 signal로 끊긴다 - 변경 알림·본인 저장 뒤에는 데이터가 없어 TanStack이 합류하는 첫 조회도 취소하고 다시 받는다. 입장·연결 복구는 구독 뒤 출발한 첫 조회에 합류해 입장 때 6개 × 2 조회가 6개가 된다 - 같은 틱에 온 요청(중복 프레임·알림 여러 개)은 다음 마이크로태스크에 한 번에 무효화한다 - 상태 기계(pending·refreshing·ready·error·revoked), 403·404 권한 해제, listRevision 펜스, 실패 기억은 그대로 둔다. 더 새 조회로 바뀌어 취소된 조회는 실패로 세지 않는다 - 테스트: observer 없이 6개를 받던 단정을 ExpenseProvider와 같은 조회를 붙인 상태로 고쳐 쓰고, 첫 조회 재시작·본인 저장 반향(요청 2번·앞 요청 중단·반영 1번) 시나리오 추가 Co-Authored-By: Claude Opus 5.5 --- .../expenses/ExpenseProvider.test.tsx | 38 ++++- src/hooks/useExpenseRecovery.test.tsx | 39 +++-- src/lib/expenses/expense-recovery.test.ts | 86 +++++++++-- src/lib/expenses/expense-recovery.ts | 138 +++++++++--------- 4 files changed, 211 insertions(+), 90 deletions(-) diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx index 884a454f..6df95954 100644 --- a/src/components/expenses/ExpenseProvider.test.tsx +++ b/src/components/expenses/ExpenseProvider.test.tsx @@ -25,7 +25,7 @@ beforeEach(() => { }); const mocks = vi.hoisted(() => ({ members: vi.fn<() => Promise>(() => new Promise(() => {})), - list: vi.fn<() => Promise>(async () => []), + list: vi.fn<(roomId?: string, signal?: AbortSignal) => Promise>(async () => []), summary: vi.fn<() => Promise>(async () => ({ currencies: [] })), budget: vi.fn(async () => ({ budgetKrw: null as string | null, @@ -264,6 +264,40 @@ it("forwards the reviewed version and reflects PATCH before summary refresh comp await saving; }); }); +it("본인 저장 뒤 반향 알림이 와도 목록은 최대 2번 받고 앞 요청은 끊으며, 받은 목록은 한 번만 반영한다", async () => { + await mountMutations(); + const updated = { ...record, version: 3, memo: "saved" }; + mocks.patch.mockResolvedValueOnce(updated); + const reads: { signal?: AbortSignal; finish: (value: Expense[]) => void }[] = []; + mocks.list.mockImplementation((_roomId, signal) => new Promise((resolve) => reads.push({ signal, finish: resolve }))); + // 저장 응답을 넣는 setQueryData(manual)를 빼고, 조회가 목록을 바꾼 횟수를 센다 + let fetchedLists = 0; + const stopCounting = client.getQueryCache().subscribe((event) => { + if (event.type === "updated" && event.action.type === "success" && !event.action.manual + && event.query.queryKey[2] === "list") fetchedLists += 1; + }); + try { + let saving!: Promise; + await act(async () => { saving = context.save({ ...record, memo: "saved" }, 10, 2); }); + expect(client.getQueryData(expenseKeys.list("r"))).toEqual([updated]); + expect(reads).toHaveLength(1); + // 서버가 저장 뒤 보낸 알림 — 누가 바꿨는지 알 수 없어 그대로 다시 받는다 + await act(async () => { + stomp.subscribe.mock.lastCall![1]({ body: JSON.stringify({ roomId: "r", type: "EXPENSES_INVALIDATED" }) }); + }); + expect(reads).toHaveLength(2); + expect(reads[0]!.signal?.aborted).toBe(true); + await act(async () => { + reads[0]!.finish([record]); + reads[1]!.finish([updated]); + await saving; + }); + expect(client.getQueryData(expenseKeys.list("r"))).toEqual([updated]); + expect(fetchedLists).toBe(1); + } finally { + stopCounting(); + } +}); it("uses a fresh list read for recovery and propagates read failure without returning cached data", async () => { await mountMutations(); mocks.list.mockResolvedValueOnce([{ ...record, version: 8 }]); @@ -906,7 +940,7 @@ it("isolates AND-filtered KRW snapshots and refreshes them on expense invalidati const { getExpenseRecovery } = await import("@/lib/expenses/expense-recovery"); await act(async () => getExpenseRecovery(client, "r").message(JSON.stringify({ roomId: "r", type: "EXPENSES_INVALIDATED" }))); expect(mocks.krw).toHaveBeenCalledWith("r", filters, expect.any(AbortSignal)); - expect(mocks.krw).toHaveBeenCalledWith("r"); + expect(mocks.krw).toHaveBeenCalledWith("r", {}, expect.any(AbortSignal)); }); it("keeps whole-room state ready while a new filter snapshot loads", async () => { diff --git a/src/hooks/useExpenseRecovery.test.tsx b/src/hooks/useExpenseRecovery.test.tsx index 0e4711ad..e3b64203 100644 --- a/src/hooks/useExpenseRecovery.test.tsx +++ b/src/hooks/useExpenseRecovery.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, useQueries } from "@tanstack/react-query"; import { afterEach, expect, it, vi } from "vitest"; import { useExpenseRecovery } from "./useExpenseRecovery"; const mocks = vi.hoisted(() => ({ @@ -23,8 +23,28 @@ vi.mock("@/lib/api/rooms/expenses", () => ({ getExpenseKrwSummary: () => mocks.read("krw"), getExpenseCurrencies: () => mocks.read("currencies"), })); +/** ExpenseProvider가 붙이는 6개 조회와 같은 key·옵션 — 복구는 무효화만 하고 이 조회들이 다시 받는다 */ +const EXPENSE_READS = [ + ["list", "list"], + ["summary", "summary"], + ["summary-krw", "krw"], + ["budget", "budget"], + ["currencies", "currencies"], + ["members", "members"], +] as const; function Probe() { - const { recovery, syncStatus } = useExpenseRecovery("r", true); + const { recovery, revoked, syncStatus } = useExpenseRecovery("r", true); + useQueries({ + queries: EXPENSE_READS.map(([cacheKey, readKey]) => ({ + queryKey: ["room-expenses", "r", cacheKey], + queryFn: () => mocks.read(readKey), + enabled: !revoked, + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + })), + }); return <>

{syncStatus}

@@ -198,26 +218,27 @@ it.each([ }); const stale = (key: string) => key === "budget" ? { budgetKrw: "260000", version: 1 } : [{ revision: 1 }]; const latest = (key: string) => key === "budget" ? { budgetKrw: "270000", version: 2 } : [{ revision: 2 }]; - // Hold all initial reads so frames arrive while older responses are in flight. + // Hold all recovery reads so frames arrive while older responses are in flight. mocks.read.mockImplementation((key) => new Promise((resolve) => deferred.set(key, resolve))); let reading!: Promise; await act(async () => { reading = recovery.refresh("all"); }); expect(deferred.has("budget")).toBe(true); expect(deferred.has("list")).toBe(true); + mocks.read.mockClear(); + mocks.read.mockImplementation(async (key) => latest(key)); await act(async () => { deliver(first); deliver(first); deliver(second); deliver(second); }); - mocks.read.mockImplementation(async (key) => latest(key)); - // Resolve the opposite resource first; the late old list/budget must be followed by new GETs. - const earlyKey = first === "EXPENSES_INVALIDATED" ? "budget" : "list"; - await act(async () => { deferred.get(earlyKey)!(stale(earlyKey)); }); + // 같은 틱의 중복 프레임은 한 번만 다시 받는다. 받는 중이던 옛 조회는 취소된다 + expect(mocks.read.mock.calls.map(([key]) => key).sort()).toEqual(["budget", "krw", "list", "summary"]); + // 아직 끝나지 않은 입장 복구(통화·멤버)가 남아 있다 expect(recovery.getSnapshot()).toBe("pending"); await act(async () => { - for (const [key, resolve] of [...deferred].reverse()) - if (key !== earlyKey) resolve(stale(key)); + // 취소된 옛 응답이 늦게 와도 새 값을 덮지 않는다 + for (const [key, resolve] of [...deferred].reverse()) resolve(stale(key)); await reading; }); expect(client.getQueryData(["room-expenses", "r", "budget"])).toEqual({ budgetKrw: "270000", version: 2 }); diff --git a/src/lib/expenses/expense-recovery.test.ts b/src/lib/expenses/expense-recovery.test.ts index 59173a74..7cb8fb31 100644 --- a/src/lib/expenses/expense-recovery.test.ts +++ b/src/lib/expenses/expense-recovery.test.ts @@ -1,5 +1,5 @@ import { evictRoomFromClientCaches } from "@/lib/stomp/forced-room-exit-dispatch"; -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { afterEach, expect, it, vi } from "vitest"; import { beginExpenseRoomAdmission, getExpenseRecovery } from "./expense-recovery"; import { expenseKeys } from "./expense-queries"; @@ -16,12 +16,40 @@ vi.mock("@/lib/api/rooms/expenses", () => ({ getExpenseBudget: () => api.read("budget"), getExpenseCurrencies: () => api.read("currencies"), })); +const stops: (() => void)[] = []; afterEach(() => { + for (const stop of stops.splice(0)) stop(); vi.clearAllMocks(); }); -it("refreshes six endpoints without active observers and routes minimal own-session events", async () => { +const READ_KEYS = ["list", "summary", "summary-krw", "budget", "currencies", "members"]; +/** + * ExpenseProvider가 화면에 붙이는 6개 조회와 같은 key·옵션의 observer를 붙인다. + * 복구는 무효화만 하고, 다시 받는 일은 화면에 있는 조회(이 observer)의 queryFn이 한다. + */ +function mountExpenseQueries(client: QueryClient, roomId = "r") { + for (const key of READ_KEYS) { + const observer = new QueryObserver(client, { + queryKey: [...expenseKeys.room(roomId), key], + queryFn: () => api.read(key), + retry: false, + staleTime: Infinity, + }); + stops.push(observer.subscribe(() => {})); + } +} +/** observer를 붙이고 첫 조회가 끝날 때까지 기다린 뒤 읽기 기록을 비운다 */ +async function loadedRecovery(client: QueryClient, roomId = "r") { + const recovery = getExpenseRecovery(client, roomId); + mountExpenseQueries(client, roomId); + await recovery.refresh("all"); + api.read.mockClear(); + return recovery; +} +it("refreshes the six mounted queries once on entry and routes minimal own-session events", async () => { const client = new QueryClient(); const recovery = getExpenseRecovery(client, "r"); + mountExpenseQueries(client); + // 입장 복구는 observer가 막 시작한 첫 조회에 합류한다(6개 × 2가 아니다) await recovery.refresh("all"); expect(api.read.mock.calls.map((c) => c[0]).sort()).toEqual([ "budget", @@ -51,8 +79,19 @@ it("refreshes six endpoints without active observers and routes minimal own-sess expect(api.read.mock.calls.map((c) => c[0])).toEqual(["budget"]); client.clear(); }); +it("does not read queries that are not on screen; it only marks them for their next mount", async () => { + const client = new QueryClient(); + const recovery = getExpenseRecovery(client, "r"); + client.setQueryData(expenseKeys.list("r"), []); + await recovery.refresh("expenses"); + expect(api.read).not.toHaveBeenCalled(); + expect(client.getQueryState(expenseKeys.list("r"))?.isInvalidated).toBe(true); + expect(recovery.getSnapshot()).toBe("ready"); + client.clear(); +}); it("coalesces a burst but reruns events received during reads", async () => { const client = new QueryClient(); + const recovery = await loadedRecovery(client); let finish!: (v: never[]) => void; api.read.mockImplementationOnce( () => @@ -60,9 +99,9 @@ it("coalesces a burst but reruns events received during reads", async () => { finish = resolve; }), ); - const recovery = getExpenseRecovery(client, "r"); const first = recovery.refresh("budget"); await vi.waitFor(() => expect(finish).toBeTypeOf("function")); + // 읽는 중에 온 알림은 그 조회를 취소하고 다시 받는다. 같은 틱의 알림 여러 개는 한 번만 다시 받는다 const second = recovery.refresh("budget"); const third = recovery.refresh("budget"); finish([]); @@ -71,10 +110,32 @@ it("coalesces a burst but reruns events received during reads", async () => { expect(recovery.getSnapshot()).toBe("ready"); client.clear(); }); +it("restarts a first load in flight when a change arrives instead of joining it", async () => { + const client = new QueryClient(); + const recovery = getExpenseRecovery(client, "r"); + const reads: ((v: never[]) => void)[] = []; + api.read.mockImplementation((key) => + key === "budget" ? new Promise((resolve) => reads.push(resolve)) : Promise.resolve([]), + ); + try { + mountExpenseQueries(client); + await vi.waitFor(() => expect(reads).toHaveLength(1)); + // 처음 받는 중(데이터 없음)인 조회는 이 변경 전에 출발했을 수 있다 + const change = recovery.refresh("budget"); + await vi.waitFor(() => expect(reads).toHaveLength(2)); + reads[1]!([]); + reads[0]!([]); + await change; + expect(recovery.getSnapshot()).toBe("ready"); + } finally { + api.read.mockImplementation(async () => []); + client.clear(); + } +}); it("rejects failed reads and does not report ready", async () => { const client = new QueryClient(); + const recovery = await loadedRecovery(client); api.read.mockRejectedValueOnce(new Error("offline")); - const recovery = getExpenseRecovery(client, "r"); await expect(recovery.refresh("budget")).rejects.toThrow("offline"); expect(recovery.getSnapshot()).toBe("error"); client.clear(); @@ -86,7 +147,7 @@ it.each([ "revokes room access and prevents late cache repopulation: %s", async (error) => { const client = new QueryClient(); - const recovery = getExpenseRecovery(client, "r"); + const recovery = await loadedRecovery(client); client.setQueryData(expenseKeys.list("r"), ["private"]); api.read.mockRejectedValueOnce(error); await expect(recovery.refresh("budget")).rejects.toEqual(error); @@ -107,8 +168,7 @@ it("record-only 404 and authentication errors do not revoke the room", () => { }); it("a successful budget event cannot hide a failed expense read", async () => { const client = new QueryClient(); - const recovery = getExpenseRecovery(client, "r"); - await recovery.refresh("all"); + const recovery = await loadedRecovery(client); api.read.mockRejectedValueOnce(new Error("offline")); await expect(recovery.refresh("expenses")).rejects.toThrow(); await recovery.refresh("budget"); @@ -119,7 +179,7 @@ it("a successful budget event cannot hide a failed expense read", async () => { }); it("forced room exit stops recovery and fences late reads", async () => { const client = new QueryClient(); - const recovery = getExpenseRecovery(client, "r"); + const recovery = await loadedRecovery(client); let finish!: (v: never[]) => void; api.read.mockImplementationOnce( () => @@ -186,6 +246,8 @@ it("fresh server admission replaces a revoked lifetime, leaving old work termina confirm("r", true); const fresh = getExpenseRecovery(client, "r"); expect(fresh).not.toBe(old); + // 새 입장의 화면이 조회를 다시 붙인다 + mountExpenseQueries(client); await fresh.refresh("all"); expect(fresh.getSnapshot()).toBe("ready"); old.handleError({ status: 403 }); @@ -241,6 +303,7 @@ it.each(["members", "currencies"])( return []; }); try { + mountExpenseQueries(client); await expect(recovery.refresh("all")).rejects.toThrow("offline"); expect(recovery.getSnapshot()).toBe("error"); api.read.mockResolvedValue([]); @@ -258,9 +321,7 @@ it.each(["members", "currencies"])( it("revalidates a ready room in the background and coalesces overlapping tab-return events", async () => { const client = new QueryClient(); - const recovery = getExpenseRecovery(client, "r"); - await recovery.refresh("all"); - api.read.mockClear(); + const recovery = await loadedRecovery(client); let finish!: (value: never[]) => void; api.read.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); const first = recovery.refresh("visible"); @@ -282,8 +343,7 @@ it("revalidates a ready room in the background and coalesces overlapping tab-ret it("promotes background revalidation to pending for a real expense change", async () => { const client = new QueryClient(); - const recovery = getExpenseRecovery(client, "r"); - await recovery.refresh("all"); + const recovery = await loadedRecovery(client); let finish!: (value: never[]) => void; api.read.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); const first = recovery.refresh("visible"); diff --git a/src/lib/expenses/expense-recovery.ts b/src/lib/expenses/expense-recovery.ts index 14e0d94a..d006978b 100644 --- a/src/lib/expenses/expense-recovery.ts +++ b/src/lib/expenses/expense-recovery.ts @@ -1,16 +1,18 @@ -import type { QueryClient } from "@tanstack/react-query"; -import { - getExpenses, - getExpenseSummary, - getExpenseKrwSummary, - getExpenseBudget, - getExpenseCurrencies, -} from "@/lib/api/rooms/expenses"; -import { getRoomMembers } from "@/lib/api/rooms/members"; +import { isCancelledError, type QueryClient } from "@tanstack/react-query"; import { expenseKeys } from "./expense-queries"; type Scope = "all" | "expenses" | "budget" | "visible"; type Status = "pending" | "refreshing" | "ready" | "error" | "revoked"; +type ReadKey = "list" | "summary" | "summary-krw" | "budget" | "currencies" | "members"; +const ALL_READS: readonly ReadKey[] = ["list", "summary", "summary-krw", "budget", "currencies", "members"]; +const SCOPE_READS: Record = { + all: ALL_READS, + visible: ALL_READS, + expenses: ["list", "summary", "summary-krw"], + budget: ["budget"], +}; +/** 같은 틱에 들어온 다시 받기 요청을 한 번에 무효화한다 */ +type Batch = { keys: Set; restartFirstLoads: boolean; promise: Promise }; let generation = 0; let revocation = 0; const controllers = new WeakMap< @@ -73,8 +75,9 @@ class ExpenseRecovery { readonly generation = ++generation; private status: Status = "pending"; private readonly listeners = new Set<() => void>(); - private readonly pending = new Set(); private readonly failed = new Set(); + private queued: Batch | null = null; + private inFlight = 0; private running: Promise | null = null; private readonly unsubscribeQueryCache: () => void; // Advances for authoritative reads and local writes, fencing late mutations. @@ -114,7 +117,6 @@ class ExpenseRecovery { if (store.current === this) store.lastRevocation = ++revocation; this.unsubscribeQueryCache(); this.listRevision++; - this.pending.clear(); this.setStatus("revoked"); void this.client.cancelQueries({ queryKey: expenseKeys.room(this.roomId) }); this.client.removeQueries({ queryKey: expenseKeys.room(this.roomId) }); @@ -139,72 +141,76 @@ class ExpenseRecovery { } return Promise.resolve(); }; + /** + * 범위의 가계부 조회를 무효화한다. 화면에 있는 조회(ExpenseProvider)만 그 queryFn으로 바로 다시 받고, + * 나머지는 다시 볼 때 받는다. 받는 중인 옛 조회는 TanStack이 취소하고(그 응답은 버려짐) 새로 받는다. + * 같은 틱에 온 요청(중복 프레임·변경 알림 여러 개)은 한 번에 무효화해 요청을 반복하지 않는다. + */ refresh = (scope: Scope): Promise => { if (this.status === "revoked") return Promise.resolve(); // A tab return revalidates cached data without clearing the visible ledger. // focus and visibilitychange can describe the same return. if (scope === "visible" && this.status === "refreshing" && this.running) return this.running; const background = scope === "visible" && this.status === "ready"; - let keys = ["list", "summary", "summary-krw", "budget", "currencies", "members"]; - if (scope === "budget") keys = ["budget"]; - else if (scope === "expenses") keys = ["list", "summary", "summary-krw"]; - keys.forEach((key) => this.pending.add(key)); this.setStatus(background ? "refreshing" : "pending"); - this.running ??= Promise.resolve() - .then(() => this.drain()) + const batch = (this.queued ??= this.startBatch()); + SCOPE_READS[scope].forEach((key) => batch.keys.add(key)); + // 변경 알림·본인 저장 뒤에는 그 변경 전에 출발했을 수 있는 첫 조회도 다시 받는다. + // 입장·연결 복구의 첫 조회는 구독 뒤에 출발했으므로 합류한다 + if (scope === "expenses" || scope === "budget") batch.restartFirstLoads = true; + return batch.promise; + }; + private startBatch(): Batch { + const batch: Batch = { + keys: new Set(), + restartFirstLoads: false, + promise: Promise.resolve(), + }; + this.inFlight++; + // 다음 마이크로태스크에 보낸다 — 같은 틱의 요청을 모으고, 입장 때는 조회 observer가 붙은 뒤에 무효화한다 + batch.promise = Promise.resolve() + .then(() => { + if (this.queued === batch) this.queued = null; + return this.invalidate([...batch.keys], batch.restartFirstLoads); + }) .finally(() => { + if (--this.inFlight > 0 || this.status === "revoked") return; this.running = null; + this.setStatus(this.failed.size ? "error" : "ready"); }); - return this.running; - }; - private async drain() { + this.running = batch.promise; + return batch; + } + private async invalidate(keys: ReadKey[], restartFirstLoads: boolean) { + if (this.status === "revoked") return; let failure: unknown; - while (this.pending.size && this.status !== "revoked") { - const keys = [...this.pending]; - this.pending.clear(); - const reads: Record Promise> = { - list: () => getExpenses(this.roomId), - summary: () => getExpenseSummary(this.roomId), - "summary-krw": () => getExpenseKrwSummary(this.roomId), - budget: () => getExpenseBudget(this.roomId), - currencies: () => getExpenseCurrencies(this.roomId), - members: () => getRoomMembers(this.roomId), - }; - const results = await Promise.allSettled( - keys.map(async (key) => { - const queryKey = ["room-expenses", this.roomId, key]; - await this.client.cancelQueries({ queryKey, exact: true }); - if (this.getSnapshot() === "revoked") return; - try { - await this.client.fetchQuery({ - queryKey, - queryFn: reads[key], - staleTime: 0, - retry: false, - }); - if (key === "summary-krw" && this.getSnapshot() !== "revoked") { - // Invalidate all filter snapshots; refresh active ones through their own queryFn. - await this.client.invalidateQueries({ - queryKey, - predicate: (query) => query.queryKey.length > 3, - }, { throwOnError: true }); - } - this.failed.delete(key); - } catch (error) { - this.failed.add(key); - this.handleError(error); - throw error; - } - }), - ); - for (const result of results) - if (result.status === "rejected") { - failure = result.reason; - this.handleError(failure); + const results = await Promise.allSettled( + keys.map(async (key) => { + const queryKey = [...expenseKeys.room(this.roomId), key]; + if (restartFirstLoads) { + // 데이터가 없는(처음 받는 중인) 조회는 TanStack이 취소하지 않고 합류만 한다 + await this.client.cancelQueries({ + queryKey, + predicate: (query) => query.state.data === undefined, + }); } - } - if (this.status !== "revoked") - this.setStatus(this.failed.size ? "error" : "ready"); - if (failure) throw failure; + try { + // summary-krw는 접두로 필터별 스냅샷도 함께 무효화한다(보는 필터만 다시 받는다) + await this.client.invalidateQueries({ queryKey }, { throwOnError: true }); + this.failed.delete(key); + } catch (error) { + // 더 새 조회로 바뀌었거나 화면이 사라져 취소된 조회는 실패가 아니다 + if (isCancelledError(error)) return; + this.failed.add(key); + throw error; + } + }), + ); + for (const result of results) + if (result.status === "rejected") { + failure ??= result.reason; + this.handleError(result.reason); + } + if (failure !== undefined) throw failure; } } From dfe841552b3171df81ae1c17e0f13a0a62e54334 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 8 Nov 2026 16:14:38 +0900 Subject: [PATCH 16/27] =?UTF-8?q?test(expenses):=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=A0=84=EC=97=90=20=EC=B6=9C=EB=B0=9C=ED=95=9C=20=EC=B2=AB=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=9D=98=20=EB=8A=A6=EC=9D=80=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=B4=20=EC=83=88=20=EA=B0=92=EC=9D=84=20=EB=8D=AE?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=EC=A7=80=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 두 응답에 다른 예산 값을 주고, 늦게 온 옛 응답 뒤에도 캐시가 새 값인지 단언한다(전에는 둘 다 빈 배열이라 덮어써도 통과했다) Co-Authored-By: Claude Opus 5.5 --- src/lib/expenses/expense-recovery.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/expenses/expense-recovery.test.ts b/src/lib/expenses/expense-recovery.test.ts index 7cb8fb31..9970f600 100644 --- a/src/lib/expenses/expense-recovery.test.ts +++ b/src/lib/expenses/expense-recovery.test.ts @@ -4,7 +4,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { beginExpenseRoomAdmission, getExpenseRecovery } from "./expense-recovery"; import { expenseKeys } from "./expense-queries"; const api = vi.hoisted(() => ({ - read: vi.fn<(_key: string) => Promise>(async () => []), + read: vi.fn<(_key: string) => Promise>(async () => []), })); vi.mock("@/lib/api/rooms/members", () => ({ getRoomMembers: () => api.read("members"), @@ -113,7 +113,7 @@ it("coalesces a burst but reruns events received during reads", async () => { it("restarts a first load in flight when a change arrives instead of joining it", async () => { const client = new QueryClient(); const recovery = getExpenseRecovery(client, "r"); - const reads: ((v: never[]) => void)[] = []; + const reads: ((v: unknown) => void)[] = []; api.read.mockImplementation((key) => key === "budget" ? new Promise((resolve) => reads.push(resolve)) : Promise.resolve([]), ); @@ -123,9 +123,11 @@ it("restarts a first load in flight when a change arrives instead of joining it" // 처음 받는 중(데이터 없음)인 조회는 이 변경 전에 출발했을 수 있다 const change = recovery.refresh("budget"); await vi.waitFor(() => expect(reads).toHaveLength(2)); - reads[1]!([]); - reads[0]!([]); + // 변경 뒤에 출발한 조회가 먼저 끝나고, 변경 전에 출발한 첫 조회가 늦게 온다 + reads[1]!({ budgetKrw: "200", version: 2 }); + reads[0]!({ budgetKrw: "100", version: 1 }); await change; + expect(client.getQueryData(expenseKeys.budget("r"))).toEqual({ budgetKrw: "200", version: 2 }); expect(recovery.getSnapshot()).toBe("ready"); } finally { api.read.mockImplementation(async () => []); From 12689343736025dbd6f0845346b1b6d78ce9d566 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:22:49 +0900 Subject: [PATCH 17/27] =?UTF-8?q?fix(expenses):=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=C2=B7=EB=B3=B8=EC=9D=B8=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EB=92=A4=20=EB=8B=A4=EC=8B=9C=20=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EB=8F=99=EC=95=88=20=EC=98=88=EC=82=B0=C2=B7=ED=95=A9=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EB=B9=84=EC=9A=B0=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 복구: 장부가 맞춰진(ready·refreshing) 상태에서 온 변경 알림·본인 저장 뒤 재조회는 refreshing으로 둔다. 입장·연결 복구·재시도(all)와 아직 맞춰지지 않은 장부는 지금처럼 pending - ExpenseProvider: 복구가 ready일 때 다른 화면의 무효화(일정 변경·멤버 이벤트)로 다시 받는 중이면 pending 대신 refreshing으로 낸다. 처음 데이터가 없으면 pending, 실패는 error 그대로 - 그동안 남은 예산·카테고리 합계가 '—'로 바뀌던 것을 막는다(AGENTS.md 로딩 원칙) - expense-recovery.test: 실제 변경이 pending으로 올리던 단정을 refreshing 유지로 바꾸고, 범위별 상태 시나리오 추가 Co-Authored-By: Claude Opus 5.5 --- .../expenses/ExpenseProvider.test.tsx | 20 ++++++++++++++++++- src/components/expenses/ExpenseProvider.tsx | 4 +++- src/lib/expenses/expense-recovery.test.ts | 16 +++++++++++++-- src/lib/expenses/expense-recovery.ts | 5 ++++- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx index 6df95954..8dab1ddd 100644 --- a/src/components/expenses/ExpenseProvider.test.tsx +++ b/src/components/expenses/ExpenseProvider.test.tsx @@ -176,7 +176,7 @@ it("does not treat partial STOMP member cache as a successful full member query" }); import type { Expense, ExpenseKrwSummary } from "@/lib/api/rooms/expenses"; -import { expenseKeys } from "@/lib/expenses/expense-queries"; +import { expenseKeys, invalidateExpenses } from "@/lib/expenses/expense-queries"; let context: ReturnType; function MutationProbe() { const value = useExpenseContext(); @@ -257,6 +257,9 @@ it("forwards the reviewed version and reflects PATCH before summary refresh comp expect.objectContaining({ expectedVersion: 2 }), ); expect(client.getQueryData(expenseKeys.list("r"))).toEqual([updated]); + // 저장 뒤 다시 받는 동안 보이던 예산·합계를 두고 갱신한다('—'로 바꾸지 않는다) + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); + expect(context.syncStatus).toBe("refreshing"); expect(analytics.send.mock.calls).toEqual([sentEvent("expense_updated")]); await act(async () => { finishList([updated]); @@ -298,6 +301,21 @@ it("본인 저장 뒤 반향 알림이 와도 목록은 최대 2번 받고 앞 stopCounting(); } }); +it("다른 화면의 무효화로 다시 받는 동안에도 장부를 비우지 않고 refreshing으로 둔다", async () => { + await mountMutations(); + expect(context.syncStatus).toBe("ready"); + let finish!: (value: unknown) => void; + mocks.summary.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + // 일정 삭제 등 다른 mutation이 가계부 전체를 무효화한다 + await act(async () => { + void invalidateExpenses(client, "r"); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(context.syncStatus).toBe("refreshing"); + expect(context.budget.data).toBeDefined(); + await act(async () => { finish({ currencies: [] }); await new Promise((resolve) => setTimeout(resolve, 20)); }); + expect(context.syncStatus).toBe("ready"); +}); it("uses a fresh list read for recovery and propagates read failure without returning cached data", async () => { await mountMutations(); mocks.list.mockResolvedValueOnce([{ ...record, version: 8 }]); diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx index 06b3a741..dfa851bb 100644 --- a/src/components/expenses/ExpenseProvider.tsx +++ b/src/components/expenses/ExpenseProvider.tsx @@ -287,7 +287,9 @@ function useExpenses(roomId: string) { let resolvedSyncStatus = syncStatus; if (syncStatus === "ready") { if (reads.some(query => query.isError)) resolvedSyncStatus = "error"; - else if (reads.some(query => !query.isSuccess || query.isFetching)) resolvedSyncStatus = "pending"; + else if (reads.some(query => !query.isSuccess)) resolvedSyncStatus = "pending"; + // 다른 화면의 무효화(일정 변경·멤버 이벤트 등)로 있던 값을 두고 다시 받는 중 — 금액을 비우지 않는다 + else if (reads.some(query => query.isFetching)) resolvedSyncStatus = "refreshing"; } return { roomId, diff --git a/src/lib/expenses/expense-recovery.test.ts b/src/lib/expenses/expense-recovery.test.ts index 9970f600..21d5f956 100644 --- a/src/lib/expenses/expense-recovery.test.ts +++ b/src/lib/expenses/expense-recovery.test.ts @@ -343,7 +343,7 @@ it("revalidates a ready room in the background and coalesces overlapping tab-ret } }); -it("promotes background revalidation to pending for a real expense change", async () => { +it("keeps the ledger visible (refreshing) when a real expense change arrives during background revalidation", async () => { const client = new QueryClient(); const recovery = await loadedRecovery(client); let finish!: (value: never[]) => void; @@ -353,7 +353,7 @@ it("promotes background revalidation to pending for a real expense change", asyn await vi.waitFor(() => expect(finish).toBeTypeOf("function")); expect(recovery.getSnapshot()).toBe("refreshing"); const change = recovery.message(JSON.stringify({ roomId: "r", type: "EXPENSES_INVALIDATED" })); - expect(recovery.getSnapshot()).toBe("pending"); + expect(recovery.getSnapshot()).toBe("refreshing"); finish([]); await Promise.all([first, change]); expect(recovery.getSnapshot()).toBe("ready"); @@ -363,3 +363,15 @@ it("promotes background revalidation to pending for a real expense change", asyn client.clear(); } }); +it.each(["expenses", "budget"] as const)("a %s change on a ready ledger refreshes in the background, but entry recovery is a foreground load", async (scope) => { + const client = new QueryClient(); + const recovery = await loadedRecovery(client); + const change = recovery.refresh(scope); + expect(recovery.getSnapshot()).toBe("refreshing"); + await change; + const entry = recovery.refresh("all"); + expect(recovery.getSnapshot()).toBe("pending"); + await entry; + expect(recovery.getSnapshot()).toBe("ready"); + client.clear(); +}); diff --git a/src/lib/expenses/expense-recovery.ts b/src/lib/expenses/expense-recovery.ts index d006978b..8a6934f0 100644 --- a/src/lib/expenses/expense-recovery.ts +++ b/src/lib/expenses/expense-recovery.ts @@ -151,7 +151,10 @@ class ExpenseRecovery { // A tab return revalidates cached data without clearing the visible ledger. // focus and visibilitychange can describe the same return. if (scope === "visible" && this.status === "refreshing" && this.running) return this.running; - const background = scope === "visible" && this.status === "ready"; + // 보이던 장부를 두고 다시 받는다(refreshing): 탭 복귀·변경 알림·본인 저장 뒤. 금액을 '—'로 바꾸지 않는다(AGENTS.md 로딩 원칙). + // 입장·연결 복구·재시도(all)와 아직 맞춰지지 않은 장부(pending·error)는 처음부터 맞춘다(pending) + const background = + scope !== "all" && (this.status === "ready" || this.status === "refreshing"); this.setStatus(background ? "refreshing" : "pending"); const batch = (this.queued ??= this.startBatch()); SCOPE_READS[scope].forEach((key) => batch.keys.add(key)); From 4d1d508352becc0caab9fb5888691da15cb77b3d Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:23:26 +0900 Subject: [PATCH 18/27] =?UTF-8?q?perf(expenses):=20=EC=9E=AC=EB=A7=88?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=EC=99=80=20=EC=9E=85=EC=9E=A5=20=EB=95=8C=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=98=EB=8D=98=20=EA=B0=80=EA=B3=84=EB=B6=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가계부 조회의 staleTime 0을 Infinity로, 멤버 조회의 refetchOnMount "always"를 지운다. 입장·연결 성립 복구와 변경 알림·본인 저장 무효화가 갱신을 맡는다 - 다시 들어올 때 마운트 재조회 6개와 입장 복구 6개가 겹쳐 12번(6번은 취소) 보내던 것이 6번이 된다. 화면 밖에서 무효화된 조회는 다시 붙을 때 받는다 - ExpenseProvider.test: 다시 들어올 때 자원마다 한 번만 읽는 시나리오 추가 Co-Authored-By: Claude Opus 5.5 --- .../expenses/ExpenseProvider.test.tsx | 17 +++++++++++++++++ src/components/expenses/ExpenseProvider.tsx | 8 +++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx index 8dab1ddd..e79a9c21 100644 --- a/src/components/expenses/ExpenseProvider.test.tsx +++ b/src/components/expenses/ExpenseProvider.test.tsx @@ -316,6 +316,23 @@ it("다른 화면의 무효화로 다시 받는 동안에도 장부를 비우지 await act(async () => { finish({ currencies: [] }); await new Promise((resolve) => setTimeout(resolve, 20)); }); expect(context.syncStatus).toBe("ready"); }); +it("다시 들어오면 재마운트 조회 없이 입장 복구 조회 한 번만 보낸다", async () => { + await mountMutations(); + await act(async () => unmount()); + const reads = [mocks.list, mocks.summary, mocks.budget, mocks.krw, mocks.members]; + for (const read of reads) read.mockClear(); + await act(async () => { + draw( + + + , + ); + }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); + // 남아 있던 캐시는 마운트만으로 다시 받지 않고, 놓친 알림을 메우는 입장 복구가 한 번씩 받는다 + for (const read of reads) expect(read).toHaveBeenCalledTimes(1); + expect(context.syncStatus).toBe("ready"); +}); it("uses a fresh list read for recovery and propagates read failure without returning cached data", async () => { await mountMutations(); mocks.list.mockResolvedValueOnce([{ ...record, version: 8 }]); diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx index dfa851bb..f3b89046 100644 --- a/src/components/expenses/ExpenseProvider.tsx +++ b/src/components/expenses/ExpenseProvider.tsx @@ -102,8 +102,8 @@ function useExpenses(roomId: string) { queryFn: ({ signal }) => getRoomMembers(roomId, signal), enabled, retry: false, - staleTime: 0, - refetchOnMount: "always", + // 다른 가계부 조회와 같이 복구·무효화로만 다시 받는다(멤버 이벤트도 이 key를 무효화한다) + staleTime: Infinity, // Visibility recovery already refreshes members with the other room data. refetchOnWindowFocus: false, }); @@ -112,10 +112,12 @@ function useExpenses(roomId: string) { !revoked && canManageExpenses(user?.id, members, memberQuery.status); const schedules = useRoomSchedules(revoked ? null : roomId || null); // Recovery owns focus/reconnect reads; manual refresh remains available. + // 입장·연결 성립 복구(refresh "all")와 변경 알림·본인 저장의 무효화로만 다시 받는다. 재마운트만으로는 받지 않아 + // 입장 때 복구 조회와 겹치지 않는다. 화면 밖에서 무효화된 조회는 다시 붙을 때 받는다 const options = { enabled, retry: false, - staleTime: 0, + staleTime: Infinity, refetchOnWindowFocus: false, refetchOnReconnect: false, }; From 121a2e1d1a6ed29f50e77d1e76e5235ca369473d Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 8 Nov 2026 15:18:36 +0900 Subject: [PATCH 19/27] =?UTF-8?q?docs(agents):=20=EC=95=84=EC=A7=81=20?= =?UTF-8?q?=EB=B0=9B=EC=A7=80=20=EB=AA=BB=ED=95=9C=20=EA=B0=92=EC=9D=80=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=8A=94=20=EC=8A=A4=EC=BC=88?= =?UTF-8?q?=EB=A0=88=ED=86=A4,=20=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=8A=94=20?= =?UTF-8?q?=EB=B9=84=EC=9B=8C=20=EB=91=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로딩 UI 원칙에 추가: 아바타·사진은 같은 크기의 스켈레톤, 이름·문장은 보이지 않게 둔다 - (알 수 없음) 같은 대체 문구는 조회가 끝난 뒤에도 값이 없을 때만 쓴다 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 7ec1f2a5..78c8a362 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ - 화면 전환과 데이터 조회 중에 `불러오는 중…`, `확인 중…` 같은 문장형 로딩 안내를 화면에 노출하지 않는다. - 최초 로딩에는 완료 후 화면과 배치·크기가 비슷한 스켈레톤을 우선 사용한다. 작은 영역에는 공통 `Spinner`(`src/components/ui/spinner`) 같은 간결한 로딩 표시를 사용한다. +- 아직 받지 못한 값의 자리에서 아바타·사진 같은 이미지는 같은 크기의 스켈레톤으로 두고, 이름·문장 같은 텍스트는 아무것도 보이지 않게 둔다. `(알 수 없음)` 같은 대체 문구는 조회가 끝난 뒤에도 값이 없을 때만 쓴다. 예: 새 멤버가 입장한 직후 멤버 목록을 다시 받는 동안 입장 메시지는 보이지 않다가 `강신규님이 입장했어요`가 바로 보인다. - 제목, 진행 현황, 버튼, 목록의 자리를 미리 확보해 로딩 전후에 화면이 튀거나 다른 화면이 잠깐 끼어 보이지 않게 한다. - 백그라운드 재조회에서는 이미 표시된 유효한 데이터를 유지하고, 새 데이터가 준비되면 갱신한다. 재조회만으로 전체 화면을 비우거나 금액을 임시 값으로 바꾸지 않는다. - 로딩 상태는 `role="status"`, `aria-label`, `aria-busy`, `sr-only` 등으로 접근성 도구에 전달할 수 있다. 화면에서 문구를 숨기는 것과 접근성 안내를 제거하는 것은 구분한다. From 5cc53900f9d9b5bbeb4c745d6af3a5acda25e137 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 8 Nov 2026 17:03:03 +0900 Subject: [PATCH 20/27] =?UTF-8?q?fix(chat):=20=EC=9D=B4=EB=A6=84=EC=9D=84?= =?UTF-8?q?=20=EC=95=84=EC=A7=81=20=EB=AA=A8=EB=A5=B4=EB=8A=94=20=EC=83=88?= =?UTF-8?q?=20=EB=A9=A4=EB=B2=84=EC=9D=98=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EB=8A=94=20=EB=A9=A4=EB=B2=84=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=84=20=EB=95=8C=EA=B9=8C=EC=A7=80=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메시지(채팅 토픽)와 이름(멤버 목록)이 따로 와서, 새 멤버의 입장 문구·메시지가 이름보다 먼저 그려져 '(알 수 없음)'이 잠깐 보였다 - 수신 콜백에서 메시지가 가리키는 사람(보낸 사람·입장한 사람·새 방장)이 목록에 없으면 멤버 목록을 무효화하고 그 메시지를 기억한다. state는 메시지 추가 한 번뿐이다 - 렌더에서 목록을 처음 받는 중이거나, 기억한 메시지인데 목록을 다시 받는 중이면 그리지 않는다. 목록이 갱신되면 같은 계산으로 제자리에 이름과 함께 보인다 - 다시 받은 목록에도 없거나, 이미 있던 메시지의 사람이 없으면(탈퇴) '(알 수 없음)'으로 둔다 - 무효화는 기본값(진행 중 조회 취소 후 새로) — 받는 중인 조회는 그 사람이 들어오기 전에 출발했을 수 있다 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useChatMessages.members.test.tsx | 174 +++++++++++++++++++++ src/hooks/useChatMessages.ts | 62 ++++++-- src/lib/chat.test.ts | 14 ++ src/lib/chat.ts | 20 +++ src/lib/stomp/members-dispatch.tsx | 2 +- 5 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 src/hooks/useChatMessages.members.test.tsx diff --git a/src/hooks/useChatMessages.members.test.tsx b/src/hooks/useChatMessages.members.test.tsx new file mode 100644 index 00000000..ffed3af8 --- /dev/null +++ b/src/hooks/useChatMessages.members.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { hookHarness } from "@/test/hook-harness"; +import type { InitialRoomHistoryLoadResult } from "@/lib/chat/initialRoomHistory"; +import type { ServerChatMessage } from "@/types/chat"; +import { useChatMessages } from "./useChatMessages"; + +type Member = { + userId: number; + nickname: string; + profileImageUrl: string | null; + role: "HOST" | "MEMBER"; + status: "ACTIVE" | "LEFT"; + joinedAt: string; + isOnline: boolean; +}; + +const mocks = vi.hoisted(() => ({ + initial: vi.fn(), + read: vi.fn(), + getMembers: vi.fn(), + stomp: { handler: null as ((msg: ServerChatMessage) => void) | null }, +})); +vi.mock("next/navigation", () => ({ usePathname: () => "/plan/room" })); +vi.mock("@/hooks/useSessionUser", () => ({ useSessionUser: () => ({ data: { id: 1 } }) })); +vi.mock("@/hooks/useChatActions", () => ({ useChatActions: () => ({}) })); +vi.mock("@/contexts/StompContext", () => { + const client = { publish: vi.fn() }; + const setRoomChatMessageHandler = (fn: ((msg: ServerChatMessage) => void) | null) => { + mocks.stomp.handler = fn; + }; + return { useStompContext: () => ({ client, connected: true, setRoomChatMessageHandler }) }; +}); +// 멤버 목록은 실제 useRoomMembers(TanStack)로 받는다 — 무효화가 같은 렌더에 반영되는지 보려고 +vi.mock("@/lib/api/rooms", async (importOriginal) => ({ + ...(await importOriginal()), + getRoomMessageReadStatus: mocks.read, + getRoomMessages: vi.fn(), + getRoomMembers: mocks.getMembers, +})); +vi.mock("@/lib/chat/initialRoomHistory", () => ({ + loadInitialRoomHistory: mocks.initial, + hasOlderHistoryPage: (count: number, size: number) => count >= size, +})); +vi.mock("@/lib/places/warmChatHistoryPlacePhotos", () => ({ warmPlacePhotoQueriesFromChatHistory: vi.fn() })); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const ROOM = "room"; +const member = (userId: number, nickname: string): Member => ({ + userId, nickname, profileImageUrl: null, role: "MEMBER", status: "ACTIVE", joinedAt: "", isOnline: true, +}); +const me = member(1, "나"); +const friend = member(2, "친구"); +const newcomer = member(9, "강신규"); +const at = (second: number) => new Date(Date.UTC(2026, 10, 8, 6, 0, second)).toISOString(); +const chat = (id: string, senderId: number, second: number): ServerChatMessage => ({ + roomId: ROOM, id, senderId, messageType: "CHAT", content: id, createdAt: at(second), sequence: second, +}); +const joined: ServerChatMessage = { + roomId: ROOM, + id: "join-9", + senderId: 0, + messageType: "SYSTEM", + content: "", + createdAt: at(10), + sequence: 10, + metadata: { eventType: "MEMBER_JOINED", userId: "9" }, +}; +const history = (serverSlice: ServerChatMessage[]): InitialRoomHistoryLoadResult => ({ + serverSlice, + hasMoreOlder: false, + hasMoreNewer: false, + lastReadMessageId: null, + warmHistory: [], +}); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +let queryClient: QueryClient; +let hook: ReturnType; +function mountHook() { + return hookHarness( + (roomId: string | null) => useChatMessages(roomId, { fetchHistory: true }), + (child) => {child}, + ); +} +const shown = () => hook.current.messages.map((m) => [m.id, m.type === "system" ? m.text : m.sender]); + +beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mocks.read.mockResolvedValue({ lastReadMessageId: null }); + mocks.initial.mockResolvedValue(history([])); + mocks.getMembers.mockResolvedValue({ members: [me, friend] }); + hook = mountHook(); +}); + +afterEach(async () => { + await hook.unmount(); + queryClient.clear(); + vi.resetAllMocks(); +}); + +it("멤버 목록에 없는 사람의 메시지는 목록을 다시 받는 동안 그리지 않다가, 제자리에 이름과 함께 보인다", async () => { + await hook.render(ROOM); + await hook.flush(); + const refetch = deferred<{ members: Member[] }>(); + mocks.getMembers.mockReturnValueOnce(refetch.promise); + + await act(async () => { + mocks.stomp.handler?.(chat("before", 2, 5)); + mocks.stomp.handler?.(joined); + mocks.stomp.handler?.(chat("after", 2, 20)); + }); + + // 받자마자 목록을 다시 받기 시작했고, 그 렌더에서 이미 숨겨져 '(알 수 없음)'이 잠깐도 보이지 않는다 + expect(mocks.getMembers).toHaveBeenCalledTimes(2); + expect(shown()).toEqual([["before", "친구"], ["after", "친구"]]); + + await act(async () => refetch.resolve({ members: [me, friend, newcomer] })); + await hook.flush(); + + // messages state는 그대로이고, 목록이 바뀌어 같은 계산이 이름을 그린다 + expect(shown()).toEqual([ + ["before", "친구"], + ["join-9", "강신규님이 입장했어요"], + ["after", "친구"], + ]); +}); + +it("다시 받은 목록에도 없으면 (알 수 없음)으로 보인다", async () => { + await hook.render(ROOM); + await hook.flush(); + + await act(async () => { + mocks.stomp.handler?.(joined); + }); + await hook.flush(); + + expect(shown()).toEqual([["join-9", "(알 수 없음)님이 입장했어요"]]); +}); + +it("이미 있던 메시지의 사람이 목록에 없으면(탈퇴) 다른 이유로 다시 받는 중에도 숨기지 않는다", async () => { + mocks.initial.mockResolvedValue(history([chat("old", 7, 1)])); + await hook.render(ROOM); + await hook.flush(); + expect(shown()).toEqual([["old", "(알 수 없음)"]]); + + mocks.getMembers.mockReturnValueOnce(new Promise(() => {})); + await act(async () => { + void queryClient.invalidateQueries({ queryKey: ["room-members", ROOM] }); + }); + + expect(shown()).toEqual([["old", "(알 수 없음)"]]); +}); + +it("멤버 목록을 처음 받는 중이면 이름을 모르는 메시지는 그리지 않고, 내 메시지는 그린다", async () => { + const firstLoad = deferred<{ members: Member[] }>(); + mocks.getMembers.mockReturnValueOnce(firstLoad.promise); + mocks.initial.mockResolvedValue(history([chat("mine", 1, 1), chat("theirs", 2, 2)])); + await hook.render(ROOM); + await hook.flush(); + expect(hook.current.messages.map((m) => m.id)).toEqual(["mine"]); + + await act(async () => firstLoad.resolve({ members: [me, friend] })); + await hook.flush(); + expect(shown()).toEqual([["mine", "나"], ["theirs", "친구"]]); +}); diff --git a/src/hooks/useChatMessages.ts b/src/hooks/useChatMessages.ts index 0046ccfe..6116153b 100644 --- a/src/hooks/useChatMessages.ts +++ b/src/hooks/useChatMessages.ts @@ -9,6 +9,7 @@ import { import { useQueryClient } from "@tanstack/react-query"; import type { ServerChatMessage } from "@/types/chat"; import { + chatMessageUserIds, deriveAiRequestConversationState, maxSequenceInMessages, mergeServerMessageLists, @@ -26,6 +27,7 @@ import { useSessionUser } from "@/hooks/useSessionUser"; import { getRoomMessageReadStatus, getRoomMessages } from "@/lib/api/rooms"; import type { RoomMember } from "@/lib/api/rooms"; import { useRoomMembers } from "@/hooks/useRooms"; +import { roomMembersQueryKey } from "@/lib/query-keys"; import { useChatActions } from "@/hooks/useChatActions"; import { useChatMessageRead } from "@/hooks/useChatMessageRead"; import { warmPlacePhotoQueriesFromChatHistory } from "@/lib/places/warmChatHistoryPlacePhotos"; @@ -116,8 +118,16 @@ export function useChatMessages( const { setRoomChatMessageHandler, connected } = useStompContext(); const { data: sessionUser } = useSessionUser(); const userId = sessionUser?.id; - const { data: membersData } = useRoomMembers(roomId); + const { + data: membersData, + isPending: membersPending, + isFetching: membersFetching, + } = useRoomMembers(roomId); const [rawMessages, setRawMessages] = useState([]); + /** STOMP로 받았을 때 보낸 사람·입장한 사람이 멤버 목록에 없던 메시지 — 목록을 다시 받는 동안 그리지 않는다 */ + const [awaitingNameIds, setAwaitingNameIds] = useState>( + () => new Set(), + ); const [hasMore, setHasMore] = useState(true); const [isFetchingOlder, setIsFetchingOlder] = useState(false); const [isFetchingNewer, setIsFetchingNewer] = useState(false); @@ -194,6 +204,7 @@ export function useChatMessages( const isFetchingOlderRef = useRef(false); const isFetchingNewerRef = useRef(false); const rawMessagesRef = useRef([]); + const membersRef = useRef(membersData); const roomLifetimeRef = useRef(0); useLayoutEffect(() => { @@ -209,7 +220,8 @@ export function useChatMessages( rawMessagesRef.current = rawMessages; activeRoomIdRef.current = roomId?.trim() ?? ""; fetchHistoryRef.current = fetchHistory; - }, [rawMessages, roomId, fetchHistory]); + membersRef.current = membersData; + }, [rawMessages, roomId, fetchHistory, membersData]); const { markMessagesRead, resetReadDedup, onIncomingMessage } = useChatMessageRead({ @@ -246,16 +258,29 @@ export function useChatMessages( const messages = useMemo( () => - rawMessages.map((m) => - serverMessageToChatMessage( - m, - userId, - memberMap, - fulfilledAiRequestIds, - aiRequestReplyLookup, - roomMembersResolved, + rawMessages + // 이름을 아직 모르는 메시지는 '(알 수 없음)' 대신 그리지 않는다. 멤버 목록을 받으면 제자리에 이름과 함께 + // 보인다(AGENTS.md 로딩 UI 원칙). 목록을 처음 받는 중이거나, STOMP로 막 받은 메시지인데 목록을 다시 받는 + // 중일 때만이다 — 이미 있던 메시지의 사람이 목록에 없으면(탈퇴) 재조회 중에도 '(알 수 없음)'으로 둔다 + .filter((m) => { + const nameMissing = chatMessageUserIds(m).some( + (id) => id !== userId && !memberMap.has(id), + ); + return !( + nameMissing && + (membersPending || (membersFetching && awaitingNameIds.has(m.id))) + ); + }) + .map((m) => + serverMessageToChatMessage( + m, + userId, + memberMap, + fulfilledAiRequestIds, + aiRequestReplyLookup, + roomMembersResolved, + ), ), - ), [ rawMessages, userId, @@ -263,6 +288,9 @@ export function useChatMessages( fulfilledAiRequestIds, aiRequestReplyLookup, roomMembersResolved, + membersPending, + membersFetching, + awaitingNameIds, ], ); @@ -829,6 +857,16 @@ export function useChatMessages( const rid = roomId.trim(); const handler = (msg: ServerChatMessage) => { if (activeRoomIdRef.current !== rid) return; + const members = membersRef.current?.members ?? []; + const nameMissing = chatMessageUserIds(msg).some( + (id) => id !== userId && !members.some((m) => m.userId === id), + ); + if (nameMissing) { + // 방금 입장한 사람 등 — 멤버 목록을 지금 다시 받는다. 받는 중인 조회는 이 사람이 들어오기 전에 출발했을 수 + // 있어 TanStack 기본값대로 취소하고 새로 받는다. 받는 동안 이 메시지는 그리지 않는다(위 messages) + void queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(rid), exact: true }); + setAwaitingNameIds((prev) => new Set(prev).add(msg.id)); + } if (liveBufferRef.current) { liveBufferRef.current.push(msg); return; @@ -837,7 +875,7 @@ export function useChatMessages( }; setRoomChatMessageHandler(handler); return () => setRoomChatMessageHandler(null); - }, [roomId, setRoomChatMessageHandler, applyLiveMessages]); + }, [roomId, userId, queryClient, setRoomChatMessageHandler, applyLiveMessages]); const rid = roomId?.trim() ?? ""; const effectiveReadMarkerMessageId = diff --git a/src/lib/chat.test.ts b/src/lib/chat.test.ts index 21862df5..e9b0c0a5 100644 --- a/src/lib/chat.test.ts +++ b/src/lib/chat.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { ServerChatMessage } from "@/types/chat"; import { + chatMessageUserIds, mergeServerMessageLists, partitionLiveMessages, replaceExistingServerMessages, @@ -138,3 +139,16 @@ describe("tailCursorOf", () => { expect(tailCursorOf([msg(undefined, "legacy")])).toBeUndefined(); }); }); + +describe("chatMessageUserIds", () => { + it("메시지를 그릴 때 이름이 필요한 사람을 돌려준다", () => { + expect(chatMessageUserIds({ ...msg(1), senderId: 9 })).toEqual([9]); + expect(chatMessageUserIds({ ...msg(2), messageType: "SYSTEM", metadata: { eventType: "MEMBER_JOINED", userId: "9" } })).toEqual([9]); + expect(chatMessageUserIds({ + ...msg(3), + messageType: "SYSTEM", + metadata: { eventType: "HOST_DELEGATED", previousHostUserId: "1", newHostUserId: "9" }, + })).toEqual([9]); + expect(chatMessageUserIds({ ...msg(4), messageType: "AI_RESPONSE" })).toEqual([]); + }); +}); diff --git a/src/lib/chat.ts b/src/lib/chat.ts index 673569e2..b69d3076 100644 --- a/src/lib/chat.ts +++ b/src/lib/chat.ts @@ -532,6 +532,26 @@ function lookupMemberNickname( return nick && nick.length > 0 ? nick : CHAT_UNKNOWN_SENDER_LABEL; } +/** + * 메시지를 그릴 때 이름이 필요한 사람 — 보낸 사람(채팅·AI 요청·장소 공유), 입장·퇴장·추방된 사람, 새 방장. + * 이름은 멤버 목록에서 찾으므로 채팅은 이들이 목록에 있을 때 메시지를 넣는다(`useChatMessages`). + */ +export function chatMessageUserIds(msg: ServerChatMessage): number[] { + const kind = normalizeMessageKind(msg.messageType); + if (kind === "CHAT" || kind === "AI_REQUEST" || kind === "PLACE_SHARE") { + const senderId = parseFiniteNumber(msg.senderId); + return senderId === undefined ? [] : [senderId]; + } + if (kind !== "SYSTEM") return []; + const eventType = readMetaString(msg.metadata, "eventType", "event_type"); + const userId = + eventType === "HOST_DELEGATED" + ? readMetaString(msg.metadata, "newHostUserId", "new_host_user_id") + : readMetaString(msg.metadata, "userId", "user_id"); + const id = parseFiniteNumber(userId); + return id === undefined ? [] : [id]; +} + /** SYSTEM metadata.eventType + 멤버 맵 lookup으로 표시 문구 조립 */ export function formatSystemEventMessage( metadata: Record | undefined, diff --git a/src/lib/stomp/members-dispatch.tsx b/src/lib/stomp/members-dispatch.tsx index cff48035..4cc6a8f6 100644 --- a/src/lib/stomp/members-dispatch.tsx +++ b/src/lib/stomp/members-dispatch.tsx @@ -27,7 +27,7 @@ function removesViewer(queryClient: QueryClient, event: RoomMemberPayload): bool * members STOMP 한 건 — 바뀐 멤버 목록을 서버에서 다시 받게 한다. 이벤트 내용으로 캐시를 고치지 않는다: * 이벤트 전에 출발한 조회가 늦게 와도 TanStack이 그 조회를 취소하고 버린다(패치는 그 응답에 덮였다). * 화면에 있는 목록만 바로 다시 받고 나머지는 다시 볼 때 받는다. - * 채팅 발신자 이름은 `room-members`에서 찾으므로, 새 멤버의 이름은 다시 받을 때까지 '(알 수 없음)'으로 보인다. + * 채팅 발신자 이름은 `room-members`에서 찾으므로, 채팅은 새 멤버의 메시지를 이 목록을 다시 받을 때까지 그리지 않는다(`useChatMessages`). */ export function dispatchRoomMemberEvent( queryClient: QueryClient, From ec77ef9a868365d3e969a9a5c847400144f8bb8e Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 14:36:15 +0900 Subject: [PATCH 21/27] =?UTF-8?q?fix(members):=20=EC=B2=AB=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EC=A4=91=EC=97=90=20=EC=98=A8=20=EB=B3=80=EA=B2=BD=EB=8F=84=20?= =?UTF-8?q?=EA=B7=B8=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EA=B3=A0=20=EC=83=88=EB=A1=9C=20=EB=B0=9B=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터가 없는 첫 조회에는 무효화가 취소하지 않고 합류해서, 방 입장 직후 들어온 새 멤버가 목록과 채팅 이름에서 빠진 채 남았다 - invalidateAfterChange(query-client)를 두어 받는 중인 첫 조회를 먼저 취소하고 새로 받는다. 데이터가 있는 조회는 무효화가 취소한다 - 멤버 이벤트, 접속 상태의 재조회, 채팅에서 이름 모르는 메시지를 받았을 때 이 함수를 쓴다 - 테스트: 첫 조회 중 변경(공용 함수·멤버 이벤트·채팅) Co-Authored-By: Claude Opus 5.5 --- src/hooks/useChatMessages.members.test.tsx | 23 ++++++++ src/hooks/useChatMessages.ts | 5 +- src/lib/query-client.test.ts | 68 ++++++++++++++++++++++ src/lib/query-client.ts | 31 +++++++++- src/lib/stomp/members-dispatch.test.ts | 18 ++++++ src/lib/stomp/members-dispatch.tsx | 11 ++-- src/lib/stomp/presence-dispatch.tsx | 5 +- 7 files changed, 151 insertions(+), 10 deletions(-) diff --git a/src/hooks/useChatMessages.members.test.tsx b/src/hooks/useChatMessages.members.test.tsx index ffed3af8..8a8fc4d3 100644 --- a/src/hooks/useChatMessages.members.test.tsx +++ b/src/hooks/useChatMessages.members.test.tsx @@ -172,3 +172,26 @@ it("멤버 목록을 처음 받는 중이면 이름을 모르는 메시지는 await hook.flush(); expect(shown()).toEqual([["mine", "나"], ["theirs", "친구"]]); }); + +it("첫 멤버 목록을 받는 중에 새 멤버가 들어와도, 그 사람이 있는 새 목록으로 이름이 보인다", async () => { + const firstLoad = deferred<{ members: Member[] }>(); + const refetch = deferred<{ members: Member[] }>(); + mocks.getMembers.mockReturnValueOnce(firstLoad.promise).mockReturnValueOnce(refetch.promise); + await hook.render(ROOM); + await hook.flush(); + + await act(async () => { + mocks.stomp.handler?.(joined); + }); + // 첫 조회에 합류하지 않고 새로 받기 시작했다 + expect(mocks.getMembers).toHaveBeenCalledTimes(2); + + await act(async () => { + refetch.resolve({ members: [me, friend, newcomer] }); + // 입장 전에 출발한 첫 응답이 늦게 와도 버려진다 + firstLoad.resolve({ members: [me, friend] }); + }); + await hook.flush(); + + expect(shown()).toEqual([["join-9", "강신규님이 입장했어요"]]); +}); diff --git a/src/hooks/useChatMessages.ts b/src/hooks/useChatMessages.ts index 6116153b..d1f9d0da 100644 --- a/src/hooks/useChatMessages.ts +++ b/src/hooks/useChatMessages.ts @@ -27,6 +27,7 @@ import { useSessionUser } from "@/hooks/useSessionUser"; import { getRoomMessageReadStatus, getRoomMessages } from "@/lib/api/rooms"; import type { RoomMember } from "@/lib/api/rooms"; import { useRoomMembers } from "@/hooks/useRooms"; +import { invalidateAfterChange } from "@/lib/query-client"; import { roomMembersQueryKey } from "@/lib/query-keys"; import { useChatActions } from "@/hooks/useChatActions"; import { useChatMessageRead } from "@/hooks/useChatMessageRead"; @@ -863,8 +864,8 @@ export function useChatMessages( ); if (nameMissing) { // 방금 입장한 사람 등 — 멤버 목록을 지금 다시 받는다. 받는 중인 조회는 이 사람이 들어오기 전에 출발했을 수 - // 있어 TanStack 기본값대로 취소하고 새로 받는다. 받는 동안 이 메시지는 그리지 않는다(위 messages) - void queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(rid), exact: true }); + // 있어 첫 조회라도 취소하고 새로 받는다. 받는 동안 이 메시지는 그리지 않는다(위 messages) + void invalidateAfterChange(queryClient, { queryKey: roomMembersQueryKey(rid), exact: true }); setAwaitingNameIds((prev) => new Set(prev).add(msg.id)); } if (liveBufferRef.current) { diff --git a/src/lib/query-client.test.ts b/src/lib/query-client.test.ts index d5dc8fa4..23daa351 100644 --- a/src/lib/query-client.test.ts +++ b/src/lib/query-client.test.ts @@ -40,3 +40,71 @@ describe("shouldRetryQuery", () => { expect(shouldRetryQuery(1, new TypeError("Failed to fetch"))).toBe(false); }); }); + +describe("invalidateAfterChange", () => { + async function setup(initialData?: string[]) { + const { QueryClient, QueryObserver } = await import("@tanstack/react-query"); + const { invalidateAfterChange } = await import("@/lib/query-client"); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const responses: ((value: string[]) => void)[] = []; + if (initialData) client.setQueryData(["members"], initialData); + const observer = new QueryObserver(client, { + queryKey: ["members"], + queryFn: () => new Promise((resolve) => responses.push(resolve)), + staleTime: Infinity, + }); + const stop = observer.subscribe(() => {}); + return { client, responses, stop, invalidateAfterChange }; + } + + it("데이터가 없는 첫 조회도 취소하고 새로 받아, 변경 전에 출발한 첫 응답을 쓰지 않는다", async () => { + const { client, responses, stop, invalidateAfterChange } = await setup(); + try { + // 마운트하자마자 첫 조회가 나갔다 + await vi.waitFor(() => expect(responses).toHaveLength(1)); + + const done = invalidateAfterChange(client, { queryKey: ["members"] }); + await vi.waitFor(() => expect(responses).toHaveLength(2)); + responses[1]!(["나", "새 멤버"]); + responses[0]!(["나"]); + await done; + + expect(client.getQueryData(["members"])).toEqual(["나", "새 멤버"]); + } finally { + stop(); + client.clear(); + } + }); + + it("데이터가 있으면 받는 중인 조회를 무효화가 취소하고 새로 받는다", async () => { + const { client, responses, stop, invalidateAfterChange } = await setup(["나"]); + try { + void client.refetchQueries({ queryKey: ["members"] }); + await vi.waitFor(() => expect(responses).toHaveLength(1)); + + const done = invalidateAfterChange(client, { queryKey: ["members"] }); + expect(responses).toHaveLength(2); + responses[1]!(["나", "새 멤버"]); + responses[0]!(["나"]); + await done; + + expect(client.getQueryData(["members"])).toEqual(["나", "새 멤버"]); + } finally { + stop(); + client.clear(); + } + }); + + it("받는 중인 조회가 없으면 기다리지 않고 바로 다시 받기 시작한다", async () => { + const { client, responses, stop, invalidateAfterChange } = await setup(["나"]); + try { + void invalidateAfterChange(client, { queryKey: ["members"] }); + // 같은 틱에 이미 '받는 중'이라 이 사이의 렌더도 받는 중으로 본다 + expect(client.getQueryState(["members"])?.fetchStatus).toBe("fetching"); + expect(responses).toHaveLength(1); + } finally { + stop(); + client.clear(); + } + }); +}); diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts index 7953a7d4..9af18316 100644 --- a/src/lib/query-client.ts +++ b/src/lib/query-client.ts @@ -1,4 +1,9 @@ -import { QueryClient } from "@tanstack/react-query"; +import { + QueryClient, + type InvalidateOptions, + type InvalidateQueryFilters, + type Query, +} from "@tanstack/react-query"; import { isNetworkError } from "@/lib/api/errors"; @@ -44,3 +49,27 @@ export function registerQueryClient(client: QueryClient): void { export function getQueryClient(): QueryClient | null { return registeredQueryClient; } + +/** + * 서버 값이 바뀌었다는 알림 뒤에 다시 받는다. 받는 중인 조회는 이 변경 전에 출발했을 수 있어 취소하고 새로 받는다. + * 데이터가 있는 조회는 무효화가 알아서 취소하지만, 데이터가 없는 첫 조회에는 TanStack이 합류해 버려서 먼저 취소한다. + * 예: 방 입장 직후 첫 멤버 조회 중에 새 멤버가 들어오면, 그 사람이 없는 첫 응답 대신 새로 받은 목록을 쓴다. + */ +export async function invalidateAfterChange( + queryClient: QueryClient, + filters: InvalidateQueryFilters, + options?: InvalidateOptions, +): Promise { + const firstLoads = { + ...filters, + predicate: (query: Query) => + query.state.data === undefined && + query.state.fetchStatus !== "idle" && + (filters.predicate?.(query) ?? true), + }; + // 취소할 첫 조회가 없으면 기다리지 않고 바로 무효화한다 — 같은 틱의 렌더가 '받는 중'을 보게 + if (queryClient.getQueryCache().findAll(firstLoads).length > 0) { + await queryClient.cancelQueries(firstLoads); + } + await queryClient.invalidateQueries(filters, options); +} diff --git a/src/lib/stomp/members-dispatch.test.ts b/src/lib/stomp/members-dispatch.test.ts index 377ddc23..4be2c067 100644 --- a/src/lib/stomp/members-dispatch.test.ts +++ b/src/lib/stomp/members-dispatch.test.ts @@ -116,6 +116,24 @@ describe("dispatchRoomMemberEvent", () => { .toEqual([ME, 2]); }); + it("첫 목록을 받는 중에 온 입장도, 그 첫 조회를 버리고 새로 받은 목록을 쓴다", async () => { + const client = newClient(); + const responses: ((value: RoomMemberListResponse) => void)[] = []; + watch(client, KEY, () => new Promise((resolve) => responses.push(resolve))); + // 마운트하자마자 나간 첫 조회(2번 입장 전 상태를 받아 올 조회) + await expect.poll(() => responses.length).toBe(1); + + dispatchRoomMemberEvent(client, ROOM_ID, joined(2)); + await expect.poll(() => responses.length).toBe(2); + + responses[1]!({ members: [member(ME), member(2)] }); + responses[0]!({ members: [member(ME)] }); + + await expect + .poll(() => client.getQueryData(KEY)?.members.map((m) => m.userId)) + .toEqual([ME, 2]); + }); + it("목록을 받은 적이 없으면 한 명짜리 목록을 만들지 않는다", () => { const client = newClient(); diff --git a/src/lib/stomp/members-dispatch.tsx b/src/lib/stomp/members-dispatch.tsx index 4cc6a8f6..694da3c1 100644 --- a/src/lib/stomp/members-dispatch.tsx +++ b/src/lib/stomp/members-dispatch.tsx @@ -9,6 +9,7 @@ import { roomDetailQueryKey, roomMembersQueryKey, } from "@/lib/query-keys"; +import { invalidateAfterChange } from "@/lib/query-client"; import { readSessionUserId } from "@/lib/session-user-cache"; import type { RoomMemberPayload } from "./member-events"; @@ -25,7 +26,7 @@ function removesViewer(queryClient: QueryClient, event: RoomMemberPayload): bool /** * members STOMP 한 건 — 바뀐 멤버 목록을 서버에서 다시 받게 한다. 이벤트 내용으로 캐시를 고치지 않는다: - * 이벤트 전에 출발한 조회가 늦게 와도 TanStack이 그 조회를 취소하고 버린다(패치는 그 응답에 덮였다). + * 이벤트 전에 출발한 조회는 첫 조회라도 취소하고 새로 받는다(`invalidateAfterChange` — 패치는 그 응답에 덮였다). * 화면에 있는 목록만 바로 다시 받고 나머지는 다시 볼 때 받는다. * 채팅 발신자 이름은 `room-members`에서 찾으므로, 채팅은 새 멤버의 메시지를 이 목록을 다시 받을 때까지 그리지 않는다(`useChatMessages`). */ @@ -37,13 +38,13 @@ export function dispatchRoomMemberEvent( const rid = String(event.roomId ?? "").trim() || subscribedRoomId; if (!rid || removesViewer(queryClient, event)) return; - void queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(rid) }); - void queryClient.invalidateQueries({ queryKey: expenseKeys.members(rid) }); + void invalidateAfterChange(queryClient, { queryKey: roomMembersQueryKey(rid) }); + void invalidateAfterChange(queryClient, { queryKey: expenseKeys.members(rid) }); if (event.type === "HOST_DELEGATED") { // 방장 판정은 방 목록·방 상세·멤버 세 출처 중 하나라도 HOST면 방장으로 본다(resolveViewerIsHost). // 하나라도 옛 역할이 남으면 이전 방장에게 "먼저 방장을 위임해 주세요"가 다시 뜬다 - void queryClient.invalidateQueries({ queryKey: ROOMS_QUERY_KEY }); - void queryClient.invalidateQueries({ queryKey: roomDetailQueryKey(rid) }); + void invalidateAfterChange(queryClient, { queryKey: ROOMS_QUERY_KEY }); + void invalidateAfterChange(queryClient, { queryKey: roomDetailQueryKey(rid) }); } } diff --git a/src/lib/stomp/presence-dispatch.tsx b/src/lib/stomp/presence-dispatch.tsx index 1008f162..ed62434c 100644 --- a/src/lib/stomp/presence-dispatch.tsx +++ b/src/lib/stomp/presence-dispatch.tsx @@ -4,6 +4,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { RoomMemberListResponse } from "@/lib/api/rooms"; import type { RoomPresenceChangedEvent } from "@/lib/stomp/events"; +import { invalidateAfterChange } from "@/lib/query-client"; import { roomMembersQueryKey } from "@/lib/query-keys"; import { readSessionUserId } from "@/lib/session-user-cache"; @@ -28,9 +29,9 @@ function patchMemberOnline( const queryKey = roomMembersQueryKey(rid); const state = queryClient.getQueryState(queryKey); - // 옛 조회를 취소하고 새로 받는다. 목록이 아직 없는 첫 조회는 TanStack이 취소하지 않고 합류하며, 구독 직후 재동기화가 맞춘다 + // 받는 중인 조회는 이 접속 변화 전에 출발했을 수 있어, 첫 조회라도 취소하고 새로 받는다 if (state?.fetchStatus === "fetching") { - void queryClient.invalidateQueries({ queryKey, exact: true }); + void invalidateAfterChange(queryClient, { queryKey, exact: true }); return; } // 무효화돼 다시 받을 목록은 패치하지 않는다 — setQueryData가 '갱신 필요' 표시를 지워 재조회가 사라진다 From f84adb31d0ba845da3d1ead3a204e8272aedced1 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 14:37:05 +0900 Subject: [PATCH 22/27] =?UTF-8?q?refactor(stomp):=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=C2=B7=EA=B0=80=EA=B3=84=EB=B6=80=EC=9D=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=92=A4=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=EB=8F=84=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=B7=A8=EC=86=8C=20=ED=9B=84=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EA=B8=B0=EB=A5=BC=20=EC=93=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "변경 뒤에는 받는 중인 조회를 버리고 새로 받는다"를 invalidateAfterChange 하나로 통일한다 - 북마크: STOMP 이벤트와 본인 변경 뒤 폴더 목록·전체 북마크 재조회에 사용(첫 조회 중 변경도 놓치지 않음) - 가계부: 변경 알림·본인 저장 뒤에 쓰던 '데이터 없는 조회 취소' 코드를 이 함수로 바꾼다. 입장·연결 복구는 지금처럼 첫 조회에 합류한다 Co-Authored-By: Claude Opus 5.5 --- src/lib/bookmarks-invalidation.ts | 9 +++++---- src/lib/expenses/expense-recovery.ts | 15 ++++++--------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/lib/bookmarks-invalidation.ts b/src/lib/bookmarks-invalidation.ts index af79732d..f9cbfb50 100644 --- a/src/lib/bookmarks-invalidation.ts +++ b/src/lib/bookmarks-invalidation.ts @@ -1,5 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; +import { invalidateAfterChange } from "@/lib/query-client"; import { bookmarkCategoriesQueryKey, roomAllBookmarksQueryKey, @@ -8,7 +9,7 @@ import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp"; /** * 폴더 목록(장소 개수 포함)과 방 전체 북마크를 함께 무효화합니다. 북마크가 바뀌는 STOMP 이벤트와 - * 본인 변경(mutation `onSettled`)이 같이 씁니다. 받는 중인 옛 조회는 TanStack이 취소하고 새로 받습니다. + * 본인 변경(mutation `onSettled`)이 같이 씁니다. 받는 중인 옛 조회는 첫 조회라도 취소하고 새로 받습니다. */ export async function invalidateRoomBookmarkLists( queryClient: QueryClient, @@ -16,8 +17,8 @@ export async function invalidateRoomBookmarkLists( ): Promise { const rid = roomId.trim(); await Promise.all([ - queryClient.invalidateQueries({ queryKey: bookmarkCategoriesQueryKey(rid) }), - queryClient.invalidateQueries({ queryKey: roomAllBookmarksQueryKey(rid) }), + invalidateAfterChange(queryClient, { queryKey: bookmarkCategoriesQueryKey(rid) }), + invalidateAfterChange(queryClient, { queryKey: roomAllBookmarksQueryKey(rid) }), ]); } @@ -43,7 +44,7 @@ export async function invalidateRoomBookmarkQueries( case "CATEGORY_CREATED": case "CATEGORY_UPDATED": - await queryClient.invalidateQueries({ + await invalidateAfterChange(queryClient, { queryKey: bookmarkCategoriesQueryKey(rid), }); break; diff --git a/src/lib/expenses/expense-recovery.ts b/src/lib/expenses/expense-recovery.ts index 8a6934f0..9c82ebea 100644 --- a/src/lib/expenses/expense-recovery.ts +++ b/src/lib/expenses/expense-recovery.ts @@ -1,4 +1,5 @@ import { isCancelledError, type QueryClient } from "@tanstack/react-query"; +import { invalidateAfterChange } from "@/lib/query-client"; import { expenseKeys } from "./expense-queries"; type Scope = "all" | "expenses" | "budget" | "visible"; @@ -190,16 +191,12 @@ class ExpenseRecovery { const results = await Promise.allSettled( keys.map(async (key) => { const queryKey = [...expenseKeys.room(this.roomId), key]; - if (restartFirstLoads) { - // 데이터가 없는(처음 받는 중인) 조회는 TanStack이 취소하지 않고 합류만 한다 - await this.client.cancelQueries({ - queryKey, - predicate: (query) => query.state.data === undefined, - }); - } try { - // summary-krw는 접두로 필터별 스냅샷도 함께 무효화한다(보는 필터만 다시 받는다) - await this.client.invalidateQueries({ queryKey }, { throwOnError: true }); + // summary-krw는 접두로 필터별 스냅샷도 함께 무효화한다(보는 필터만 다시 받는다). + // 변경 알림·본인 저장 뒤에는 받는 중인 첫 조회도 취소하고 새로 받는다 + await (restartFirstLoads + ? invalidateAfterChange(this.client, { queryKey }, { throwOnError: true }) + : this.client.invalidateQueries({ queryKey }, { throwOnError: true })); this.failed.delete(key); } catch (error) { // 더 새 조회로 바뀌었거나 화면이 사라져 취소된 조회는 실패가 아니다 From 7b01704fe4ea9ad412f7c781d898fa050166e8a5 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 14:38:12 +0900 Subject: [PATCH 23/27] =?UTF-8?q?fix(chat):=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EC=B2=98=EC=9D=8C=20=EB=B0=9B?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=97=90=EB=8A=94=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=8C=80=EC=8B=A0=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EB=91=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 방 페이지의 멤버 조회는 STOMP 연결 뒤에 시작해서, 그 전에는 내 메시지만 보이고 로딩 표시가 없었다 - useChatMessages가 waitingForMembers를 내보내고, ChatPanel은 그동안 목록 자리에 Spinner를 둔다(입력창은 그대로) - 목록은 멤버 목록을 받은 뒤 처음 마운트되어, 읽음 위치 스크롤도 메시지가 다 그려진 뒤에 처리된다 Co-Authored-By: Claude Opus 5.5 --- src/components/chat/ChatPanel.inline.test.tsx | 31 ++++++++++++- src/components/chat/ChatPanel.tsx | 44 ++++++++++++------- src/hooks/useChatMessages.members.test.tsx | 4 +- src/hooks/useChatMessages.ts | 2 + 4 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/components/chat/ChatPanel.inline.test.tsx b/src/components/chat/ChatPanel.inline.test.tsx index 89f4f9f4..e8c3a9f7 100644 --- a/src/components/chat/ChatPanel.inline.test.tsx +++ b/src/components/chat/ChatPanel.inline.test.tsx @@ -2,13 +2,14 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vitest"; -const boundary = vi.hoisted(() => ({ sendAi: vi.fn(), sendChat: vi.fn(), pathname: "/chat", push: vi.fn(), back: vi.fn(), replace: vi.fn() })); +const boundary = vi.hoisted(() => ({ sendAi: vi.fn(), sendChat: vi.fn(), pathname: "/chat", push: vi.fn(), back: vi.fn(), replace: vi.fn(), waitingForMembers: false })); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: boundary.push, back: boundary.back, replace: boundary.replace }), usePathname: () => boundary.pathname })); vi.mock("@/hooks/useCurrentRoomTitle", () => ({ useCurrentRoomTitle: () => "제주 여행" })); vi.mock("@/hooks/use-room-id", () => ({ useCurrentRoomId: () => ({ roomId: "room" }) })); vi.mock("@/hooks/useRooms", () => ({ useRoomMembers: () => ({ data: { members: [] } }) })); vi.mock("@/hooks/useChatMessages", () => ({ useChatMessages: () => ({ messages: [{ id: "hello", type: "system", text: "여행 대화를 시작합니다" }], + waitingForMembers: boundary.waitingForMembers, sendChatMessage: boundary.sendChat, sendAiMessage: boundary.sendAi, sendCancelAiRequest: vi.fn(), fetchOlderMessages: vi.fn(), fetchNewerMessages: vi.fn(), jumpToLatest: vi.fn(), hasMore: false, hasMoreNewer: false, @@ -76,3 +77,31 @@ it("keeps messages and the working AI composer inside one inline panel", async ( useChatPanelStore.setState({ minimized: false }); vi.unstubAllGlobals(); } }); + +it("보낸 사람 이름을 찾을 멤버 목록을 받기 전에는 메시지 대신 로딩 표시를 두고, 입력창은 그대로 둔다", async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }); + vi.stubGlobal("IntersectionObserver", class { observe() {} unobserve() {} disconnect() {} }); + HTMLElement.prototype.scrollTo = vi.fn(); + HTMLElement.prototype.scrollIntoView = vi.fn(); + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + useChatPanelStore.setState({ minimized: false }); + boundary.waitingForMembers = true; + try { + await act(async () => root.render()); + expect(host.querySelector('[aria-label="채팅 불러오는 중"]')).not.toBeNull(); + expect(host.textContent).not.toContain("여행 대화를 시작합니다"); + expect(host.querySelectorAll("textarea")).toHaveLength(1); + + boundary.waitingForMembers = false; + await act(async () => root.render()); + expect(host.querySelector('[aria-label="채팅 불러오는 중"]')).toBeNull(); + expect(host.textContent).toContain("여행 대화를 시작합니다"); + } finally { + boundary.waitingForMembers = false; + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + } +}); diff --git a/src/components/chat/ChatPanel.tsx b/src/components/chat/ChatPanel.tsx index 8eb6989a..43dc345a 100644 --- a/src/components/chat/ChatPanel.tsx +++ b/src/components/chat/ChatPanel.tsx @@ -6,6 +6,7 @@ import { useChat } from "@/hooks/useChat"; import { useChatMessages } from "@/hooks/useChatMessages"; import { useCurrentRoomId } from "@/hooks/use-room-id"; import { ChatPanelHeader } from "./ChatPanelHeader"; +import { Spinner } from "@/components/ui/spinner"; import { ChatMessageList } from "./messages/ChatMessageList"; import { ChatInputBar } from "./ChatInputBar"; import { ChatRateLimitBanner } from "./ChatRateLimitBanner"; @@ -61,6 +62,7 @@ export function ChatPanel({ const { messages, + waitingForMembers, sendChatMessage, sendAiMessage, sendCancelAiRequest, @@ -94,23 +96,31 @@ export function ChatPanel({ onClose={closeChat} /> ) : null} - + {waitingForMembers ? ( + // 보낸 사람 이름(멤버 목록)을 받기 전에는 메시지 대신 로딩 표시 — 받으면 이름과 함께 한 번에 그린다. + // 목록은 이때 처음 마운트되므로 읽음 위치 스크롤도 메시지가 다 그려진 뒤에 처리된다 +
+ +
+ ) : ( + + )} {rateLimit ? ( { +it("멤버 목록을 처음 받는 중이면 로딩 상태이고, 이름을 모르는 메시지는 그리지 않는다", async () => { const firstLoad = deferred<{ members: Member[] }>(); mocks.getMembers.mockReturnValueOnce(firstLoad.promise); mocks.initial.mockResolvedValue(history([chat("mine", 1, 1), chat("theirs", 2, 2)])); await hook.render(ROOM); await hook.flush(); + expect(hook.current.waitingForMembers).toBe(true); expect(hook.current.messages.map((m) => m.id)).toEqual(["mine"]); await act(async () => firstLoad.resolve({ members: [me, friend] })); await hook.flush(); + expect(hook.current.waitingForMembers).toBe(false); expect(shown()).toEqual([["mine", "나"], ["theirs", "친구"]]); }); diff --git a/src/hooks/useChatMessages.ts b/src/hooks/useChatMessages.ts index d1f9d0da..9e5f3ee2 100644 --- a/src/hooks/useChatMessages.ts +++ b/src/hooks/useChatMessages.ts @@ -891,6 +891,8 @@ export function useChatMessages( return { messages, + /** 보낸 사람 이름을 찾을 멤버 목록을 아직 한 번도 받지 못했다 — 목록 대신 로딩 표시를 둔다 */ + waitingForMembers: Boolean(roomId) && membersPending, sendChatMessage, sendAiMessage, sendCancelAiRequest, From 0d7a9e5e2cf1045e37c40ac7fdde31de60c83f58 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 14:38:44 +0900 Subject: [PATCH 24/27] =?UTF-8?q?fix(bookmarks):=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=EC=97=90=EC=84=9C=EB=8F=84=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=A5=BC=20=EC=97=B4=EA=B8=B0=20=EC=A0=84=EC=97=90=20=EB=B6=81?= =?UTF-8?q?=EB=A7=88=ED=81=AC=EB=A5=BC=20=EB=AF=B8=EB=A6=AC=20=EB=B0=9B?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1번 커밋에서 시딩용으로 보고 지운 useAllRoomBookmarks 두 줄이, 지도(같은 목록을 받음)가 없는 모바일에서는 유일한 미리 받기였다 - 폴더 목록 화면: 폴더를 누를 때 GET·스켈레톤이 생기던 것을 다시 미리 받는다 - 일정 "북마크에서 추가": 폴더 목록을 받은 뒤에야 북마크를 받던 직렬 대기를 없앤다 - 같은 key라 요청 수는 그대로(전체 목록 1번) Co-Authored-By: Claude Opus 5.5 --- src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx | 3 +++ .../(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx | 3 +++ src/components/layout/planning-chrome.test.tsx | 1 + 3 files changed, 7 insertions(+) diff --git a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx index 6735b9c7..60bf6161 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx @@ -25,6 +25,7 @@ import { import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { pickUniqueUntitledBookmarkCategoryName } from "@/lib/bookmark-untitled-category-name"; import { + useAllRoomBookmarks, useBookmarkCategories, useCreateBookmarkCategory, useDeleteBookmarkCategory, @@ -42,6 +43,8 @@ import { FolderRibbonIcon } from "./FolderRibbonIcon"; export function BookmarkFoldersView() { const roomId = useSessionStore((s) => s.currentRoomId); const { folders } = useBookmarkFolders(); + // 폴더를 열기 전에 북마크를 미리 받아 둔다 — 지도(같은 목록을 받음)가 없는 모바일에서도 폴더가 바로 열리게. 같은 key라 요청은 1번 + useAllRoomBookmarks(roomId); const { isPending, isLoadingError, error, refetch } = useBookmarkCategories(roomId); const { mutate: createCategory, isPending: isCreating } = diff --git a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx index d5f128d1..f929a765 100644 --- a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx +++ b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx @@ -20,6 +20,7 @@ import { toast } from "sonner"; import { FolderRibbonIcon } from "@/app/(main)/bookmark/_components/FolderRibbonIcon"; import { BookmarkPlacePreviewCard } from "@/app/(main)/bookmark/_components/BookmarkPlacePreviewCard"; import { + useAllRoomBookmarks, useBookmarkCategories, useCreateScheduleItem, useRoomBookmarks, @@ -66,6 +67,8 @@ export function AddFromBookmarkModal({ error: categoriesErr, refetch: refetchCategories, } = useBookmarkCategories(roomId); + // 첫 폴더가 정해지기 전에 북마크를 폴더 목록과 함께 받기 시작한다(폴더 → 북마크 순서로 기다리지 않게) + useAllRoomBookmarks(roomId); useEffect(() => { if (!categories?.length) return; diff --git a/src/components/layout/planning-chrome.test.tsx b/src/components/layout/planning-chrome.test.tsx index 5b68cc72..05dc1e9c 100644 --- a/src/components/layout/planning-chrome.test.tsx +++ b/src/components/layout/planning-chrome.test.tsx @@ -11,6 +11,7 @@ vi.mock("@/hooks/useCurrentRoomMembership", () => ({ useCurrentRoomMembership: ( vi.mock("@/hooks/useRoomDetail", () => ({ useRoomDetail: () => ({ data: undefined }) })); vi.mock("@/hooks/useRooms", () => ({ useRoomsList: () => ({ data: { rooms: [] }, isPending: false }), + useAllRoomBookmarks: vi.fn(), useBookmarkCategories: () => ({ isPending: false, isError: false }), useCreateBookmarkCategory: () => ({}), useDeleteBookmarkCategory: () => ({}), From 1a2288f8128bee1b04f43a778c2c0c24e1542bed Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 14:40:26 +0900 Subject: [PATCH 25/27] =?UTF-8?q?test(members):=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EA=B0=80=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20signal=EC=9D=84=20HTTP=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=20=EB=84=98=EA=B8=B0=EB=8A=94=EC=A7=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signal을 주면 apiFetch init에 그대로 넘긴다 - signal 없이 부르면 지금처럼 init 없이 보낸다 Co-Authored-By: Claude Opus 5.5 --- src/lib/api/rooms/members.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/lib/api/rooms/members.test.ts diff --git a/src/lib/api/rooms/members.test.ts b/src/lib/api/rooms/members.test.ts new file mode 100644 index 00000000..0210f1ba --- /dev/null +++ b/src/lib/api/rooms/members.test.ts @@ -0,0 +1,27 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const fetcher = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/api/client", () => ({ apiFetch: fetcher })); +vi.mock("@/lib/api/config", () => ({ API_BASE: "http://fixture" })); + +import { getRoomMembers } from "./members"; + +beforeEach(() => fetcher.mockReset()); + +it("멤버 목록 조회에 취소 signal을 넘겨, 무효화로 취소된 옛 조회의 HTTP 요청도 끊는다", async () => { + const controller = new AbortController(); + fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ members: [] }))); + + await getRoomMembers("room", controller.signal); + + expect(fetcher.mock.lastCall?.[0]).toBe("http://fixture/rooms/room/members"); + expect(fetcher.mock.lastCall?.[1]).toEqual({ signal: controller.signal }); +}); + +it("signal 없이 부르면 지금처럼 init 없이 보낸다", async () => { + fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ members: [] }))); + + await getRoomMembers("room"); + + expect(fetcher.mock.lastCall?.[1]).toBeUndefined(); +}); From a1e969a224384f7396b6b69f20a1400a93ea8700 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 14:40:26 +0900 Subject: [PATCH 26/27] =?UTF-8?q?test(rooms):=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EC=97=86=EB=8A=94=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=9D=98=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=20=EB=8C=80=EA=B8=B0?= =?UTF-8?q?=EC=99=80=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=AC=B4=ED=9A=A8?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20=ED=99=95=EC=9D=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 강퇴·입장 승인·거절·북마크 삭제는 목록을 다시 받을 때까지 mutateAsync가 끝나지 않는다 - 강퇴·승인·거절·방장 위임·북마크 삭제가 실패해도 관련 key를 무효화한다 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useRooms.mutations.test.tsx | 124 ++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/hooks/useRooms.mutations.test.tsx diff --git a/src/hooks/useRooms.mutations.test.tsx b/src/hooks/useRooms.mutations.test.tsx new file mode 100644 index 00000000..ac9fd785 --- /dev/null +++ b/src/hooks/useRooms.mutations.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { QueryClient, QueryClientProvider, QueryObserver } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { hookHarness } from "@/test/hook-harness"; +import { + bookmarkCategoriesQueryKey, + joinRequestsQueryKey, + ROOMS_QUERY_KEY, + roomAllBookmarksQueryKey, + roomDetailQueryKey, + roomMembersQueryKey, +} from "@/lib/query-keys"; +import { + useApproveJoinRequest, + useDeleteRoomBookmarkItem, + useKickMember, + useRejectJoinRequest, + useTransferHost, +} from "./useRooms"; + +const api = vi.hoisted(() => ({ + kick: vi.fn(), + approve: vi.fn(), + reject: vi.fn(), + transfer: vi.fn(), + deleteBookmark: vi.fn(), +})); +vi.mock("next/navigation", () => ({ usePathname: () => "/plan/room" })); +vi.mock("@/lib/api/rooms", async (importOriginal) => ({ + ...(await importOriginal()), + kickMember: api.kick, + approveJoinRequest: api.approve, + rejectJoinRequest: api.reject, + transferHost: api.transfer, + deleteRoomBookmark: api.deleteBookmark, +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const ROOM = "room"; +let client: QueryClient; +let hook: ReturnType; +const stops: (() => void)[] = []; + +function mountHook() { + return hookHarness( + () => ({ + kick: useKickMember(), + approve: useApproveJoinRequest(), + reject: useRejectJoinRequest(), + transfer: useTransferHost(), + deleteItem: useDeleteRoomBookmarkItem(), + }), + (child) => {child}, + ); +} + +/** 화면에 붙은 조회처럼 observer를 붙이고, 다시 받을 때의 응답은 테스트가 풀 때까지 붙잡는다 */ +function watchRefetch(queryKey: readonly unknown[]) { + let finish: (() => void) | undefined; + client.setQueryData(queryKey, []); + const observer = new QueryObserver(client, { + queryKey, + queryFn: () => new Promise((resolve) => { finish = () => resolve([]); }), + staleTime: Infinity, + }); + stops.push(observer.subscribe(() => {})); + return { finish: () => finish?.() }; +} + +beforeEach(async () => { + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + hook = mountHook(); + await hook.render(undefined); +}); + +afterEach(async () => { + for (const stop of stops.splice(0)) stop(); + await hook.unmount(); + client.clear(); + vi.resetAllMocks(); +}); + +describe("응답 본문이 없는 변경은 목록을 다시 받을 때까지 끝나지 않는다(진행 표시 유지)", () => { + it.each([ + ["강퇴", () => hook.current.kick.mutateAsync({ roomId: ROOM, userId: 2 }), api.kick, roomMembersQueryKey(ROOM)], + ["입장 승인", () => hook.current.approve.mutateAsync({ roomId: ROOM, requestId: 1 }), api.approve, joinRequestsQueryKey(ROOM)], + ["입장 거절", () => hook.current.reject.mutateAsync({ roomId: ROOM, requestId: 1 }), api.reject, joinRequestsQueryKey(ROOM)], + ["북마크 삭제", () => hook.current.deleteItem.mutateAsync({ roomId: ROOM, bookmarkId: 10, categoryId: 1 }), api.deleteBookmark, roomAllBookmarksQueryKey(ROOM)], + ] as const)("%s", async (_name, run, request, queryKey) => { + request.mockResolvedValueOnce(undefined); + const list = watchRefetch(queryKey); + let done = false; + + await act(async () => { + void run().then(() => { done = true; }); + }); + expect(done).toBe(false); + + await act(async () => list.finish()); + expect(done).toBe(true); + }); +}); + +describe("실패해도 서버 기준으로 다시 맞춘다", () => { + it.each([ + ["강퇴", () => hook.current.kick.mutateAsync({ roomId: ROOM, userId: 2 }), api.kick, [roomMembersQueryKey(ROOM), ROOMS_QUERY_KEY]], + ["입장 승인", () => hook.current.approve.mutateAsync({ roomId: ROOM, requestId: 1 }), api.approve, [joinRequestsQueryKey(ROOM), roomMembersQueryKey(ROOM), ROOMS_QUERY_KEY]], + ["입장 거절", () => hook.current.reject.mutateAsync({ roomId: ROOM, requestId: 1 }), api.reject, [joinRequestsQueryKey(ROOM)]], + ["방장 위임", () => hook.current.transfer.mutateAsync({ roomId: ROOM, targetUserId: 2 }), api.transfer, [roomMembersQueryKey(ROOM), roomDetailQueryKey(ROOM), ROOMS_QUERY_KEY]], + ["북마크 삭제", () => hook.current.deleteItem.mutateAsync({ roomId: ROOM, bookmarkId: 10, categoryId: 1 }), api.deleteBookmark, [bookmarkCategoriesQueryKey(ROOM), roomAllBookmarksQueryKey(ROOM)]], + ] as const)("%s", async (_name, run, request, queryKeys) => { + request.mockRejectedValueOnce(new Error("offline")); + for (const queryKey of queryKeys) client.setQueryData(queryKey, []); + + await act(async () => { + await expect(run()).rejects.toThrow("offline"); + }); + + for (const queryKey of queryKeys) { + expect(client.getQueryState(queryKey)?.isInvalidated).toBe(true); + } + }); +}); From 2c69e56f0d5e07642a94f523e9e34d1856a821f5 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 15:00:17 +0900 Subject: [PATCH 27/27] =?UTF-8?q?test(chat):=20=EC=8B=A4=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20sequence=EB=A5=BC=20=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=A7=80=EA=B2=8C=20=ED=95=B4=20=EC=83=88=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=20=EC=9D=B4=EB=A6=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=EB=B6=99=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 5·10·20번처럼 건너뛴 번호는 빠진 구간으로 처리돼 메시지가 붙지 않아 단언이 빈 목록과 비교되고 있었다 - 빈 방 다음 1번부터 이어지게 바꾸고, 입장 메시지는 번호를 받는 함수로 바꾼다 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useChatMessages.members.test.tsx | 23 +++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/hooks/useChatMessages.members.test.tsx b/src/hooks/useChatMessages.members.test.tsx index 43211c55..991cfeec 100644 --- a/src/hooks/useChatMessages.members.test.tsx +++ b/src/hooks/useChatMessages.members.test.tsx @@ -55,19 +55,20 @@ const me = member(1, "나"); const friend = member(2, "친구"); const newcomer = member(9, "강신규"); const at = (second: number) => new Date(Date.UTC(2026, 10, 8, 6, 0, second)).toISOString(); -const chat = (id: string, senderId: number, second: number): ServerChatMessage => ({ - roomId: ROOM, id, senderId, messageType: "CHAT", content: id, createdAt: at(second), sequence: second, +// sequence는 빈 방 다음 1번부터 이어져야 실시간 메시지가 바로 붙는다(건너뛰면 빠진 구간으로 보고 따라잡기 조회) +const chat = (id: string, senderId: number, sequence: number): ServerChatMessage => ({ + roomId: ROOM, id, senderId, messageType: "CHAT", content: id, createdAt: at(sequence), sequence, }); -const joined: ServerChatMessage = { +const joined = (sequence: number): ServerChatMessage => ({ roomId: ROOM, id: "join-9", senderId: 0, messageType: "SYSTEM", content: "", - createdAt: at(10), - sequence: 10, + createdAt: at(sequence), + sequence, metadata: { eventType: "MEMBER_JOINED", userId: "9" }, -}; +}); const history = (serverSlice: ServerChatMessage[]): InitialRoomHistoryLoadResult => ({ serverSlice, hasMoreOlder: false, @@ -114,9 +115,9 @@ it("멤버 목록에 없는 사람의 메시지는 목록을 다시 받는 동 mocks.getMembers.mockReturnValueOnce(refetch.promise); await act(async () => { - mocks.stomp.handler?.(chat("before", 2, 5)); - mocks.stomp.handler?.(joined); - mocks.stomp.handler?.(chat("after", 2, 20)); + mocks.stomp.handler?.(chat("before", 2, 1)); + mocks.stomp.handler?.(joined(2)); + mocks.stomp.handler?.(chat("after", 2, 3)); }); // 받자마자 목록을 다시 받기 시작했고, 그 렌더에서 이미 숨겨져 '(알 수 없음)'이 잠깐도 보이지 않는다 @@ -139,7 +140,7 @@ it("다시 받은 목록에도 없으면 (알 수 없음)으로 보인다", asyn await hook.flush(); await act(async () => { - mocks.stomp.handler?.(joined); + mocks.stomp.handler?.(joined(1)); }); await hook.flush(); @@ -183,7 +184,7 @@ it("첫 멤버 목록을 받는 중에 새 멤버가 들어와도, 그 사람이 await hook.flush(); await act(async () => { - mocks.stomp.handler?.(joined); + mocks.stomp.handler?.(joined(1)); }); // 첫 조회에 합류하지 않고 새로 받기 시작했다 expect(mocks.getMembers).toHaveBeenCalledTimes(2);