diff --git a/AGENTS.md b/AGENTS.md index 532af121..ae0bd82a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ - 화면 전환과 데이터 조회 중에 `불러오는 중…`, `확인 중…` 같은 문장형 로딩 안내를 화면에 노출하지 않는다. - 최초 로딩에는 완료 후 화면과 배치·크기가 비슷한 스켈레톤을 우선 사용한다. 작은 영역에는 공통 `Spinner`(`src/components/ui/spinner`) 같은 간결한 로딩 표시를 사용한다. +- 아직 받지 못한 값의 자리에서 아바타·사진 같은 이미지는 같은 크기의 스켈레톤으로 두고, 이름·문장 같은 텍스트는 아무것도 보이지 않게 둔다. `(알 수 없음)` 같은 대체 문구는 조회가 끝난 뒤에도 값이 없을 때만 쓴다. 예: 새 멤버가 입장한 직후 멤버 목록을 다시 받는 동안 입장 메시지는 보이지 않다가 `강신규님이 입장했어요`가 바로 보인다. - 제목, 진행 현황, 버튼, 목록의 자리를 미리 확보해 로딩 전후에 화면이 튀거나 다른 화면이 잠깐 끼어 보이지 않게 한다. - 백그라운드 재조회에서는 이미 표시된 유효한 데이터를 유지하고, 새 데이터가 준비되면 갱신한다. 재조회만으로 전체 화면을 비우거나 금액을 임시 값으로 바꾸지 않는다. - 로딩 상태는 `role="status"`, `aria-label`, `aria-busy`, `sr-only` 등으로 접근성 도구에 전달할 수 있다. 화면에서 문구를 숨기는 것과 접근성 안내를 제거하는 것은 구분한다. 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..60bf6161 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx @@ -43,6 +43,7 @@ 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); diff --git a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx index 65d468d8..8739342f 100644 --- a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx +++ b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx @@ -67,7 +67,7 @@ export function AddFromBookmarkModal({ error: categoriesErr, refetch: refetchCategories, } = useBookmarkCategories(roomId); - + // 첫 폴더가 정해지기 전에 북마크를 폴더 목록과 함께 받기 시작한다(폴더 → 북마크 순서로 기다리지 않게) useAllRoomBookmarks(roomId); useEffect(() => { 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 ? ( { }); 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, @@ -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]); @@ -264,6 +267,72 @@ 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("다른 화면의 무효화로 다시 받는 동안에도 장부를 비우지 않고 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("다시 들어오면 재마운트 조회 없이 입장 복구 조회 한 번만 보낸다", 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 }]); @@ -899,14 +968,14 @@ 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"); + expect(mocks.krw).toHaveBeenCalledWith("r", filters, expect.any(AbortSignal)); + expect(mocks.krw).toHaveBeenCalledWith("r", {}, expect.any(AbortSignal)); }); it("keeps whole-room state ready while a new filter snapshot loads", async () => { @@ -937,7 +1006,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..f3b89046 100644 --- a/src/components/expenses/ExpenseProvider.tsx +++ b/src/components/expenses/ExpenseProvider.tsx @@ -99,11 +99,11 @@ 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, - refetchOnMount: "always", + // 다른 가계부 조회와 같이 복구·무효화로만 다시 받는다(멤버 이벤트도 이 key를 무효화한다) + staleTime: Infinity, // Visibility recovery already refreshes members with the other room data. refetchOnWindowFocus: false, }); @@ -112,32 +112,34 @@ 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, }; 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 +148,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 +198,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 ( @@ -287,7 +289,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, @@ -320,7 +324,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 +345,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/hooks/useBookmarkCategories.test.tsx b/src/hooks/useBookmarkCategories.test.tsx index a265380f..939a308f 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, useCreateRoomBookmarksInCategories, useDeleteBookmarkCategory, useMoveRoomBookmark, useRoomBookmarks, useUpdateBookmarkCategory } 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(), 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); @@ -32,3 +32,127 @@ 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(); } +}); + +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(); } +}); + +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(); } +}); + +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(); } +}); diff --git a/src/hooks/useChatMessages.members.test.tsx b/src/hooks/useChatMessages.members.test.tsx new file mode 100644 index 00000000..991cfeec --- /dev/null +++ b/src/hooks/useChatMessages.members.test.tsx @@ -0,0 +1,200 @@ +// @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(); +// 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 = (sequence: number): ServerChatMessage => ({ + roomId: ROOM, + id: "join-9", + senderId: 0, + messageType: "SYSTEM", + content: "", + createdAt: at(sequence), + sequence, + 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, 1)); + mocks.stomp.handler?.(joined(2)); + mocks.stomp.handler?.(chat("after", 2, 3)); + }); + + // 받자마자 목록을 다시 받기 시작했고, 그 렌더에서 이미 숨겨져 '(알 수 없음)'이 잠깐도 보이지 않는다 + 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(1)); + }); + 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.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", "친구"]]); +}); + +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(1)); + }); + // 첫 조회에 합류하지 않고 새로 받기 시작했다 + 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 0046ccfe..9e5f3ee2 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,8 @@ 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"; import { warmPlacePhotoQueriesFromChatHistory } from "@/lib/places/warmChatHistoryPlacePhotos"; @@ -116,8 +119,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 +205,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 +221,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 +259,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 +289,9 @@ export function useChatMessages( fulfilledAiRequestIds, aiRequestReplyLookup, roomMembersResolved, + membersPending, + membersFetching, + awaitingNameIds, ], ); @@ -829,6 +858,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) { + // 방금 입장한 사람 등 — 멤버 목록을 지금 다시 받는다. 받는 중인 조회는 이 사람이 들어오기 전에 출발했을 수 + // 있어 첫 조회라도 취소하고 새로 받는다. 받는 동안 이 메시지는 그리지 않는다(위 messages) + void invalidateAfterChange(queryClient, { queryKey: roomMembersQueryKey(rid), exact: true }); + setAwaitingNameIds((prev) => new Set(prev).add(msg.id)); + } if (liveBufferRef.current) { liveBufferRef.current.push(msg); return; @@ -837,7 +876,7 @@ export function useChatMessages( }; setRoomChatMessageHandler(handler); return () => setRoomChatMessageHandler(null); - }, [roomId, setRoomChatMessageHandler, applyLiveMessages]); + }, [roomId, userId, queryClient, setRoomChatMessageHandler, applyLiveMessages]); const rid = roomId?.trim() ?? ""; const effectiveReadMarkerMessageId = @@ -852,6 +891,8 @@ export function useChatMessages( return { messages, + /** 보낸 사람 이름을 찾을 멤버 목록을 아직 한 번도 받지 못했다 — 목록 대신 로딩 표시를 둔다 */ + waitingForMembers: Boolean(roomId) && membersPending, sendChatMessage, sendAiMessage, sendCancelAiRequest, 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/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.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); + } + }); +}); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 5fb5ac77..aadeba39 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, @@ -16,7 +17,6 @@ import { HttpError, approveJoinRequest, createBookmarkCategory, - createRoomBookmark, createRoomBookmarks, createRoom, createRoomSchedule, @@ -32,7 +32,7 @@ import { getBookmarkCategories, getJoinRequests, getJoinStatus, - getRoomBookmarks, + getAllRoomBookmarks, getRoomMembers, patchRoomBookmarkCategory, getRooms, @@ -65,16 +65,15 @@ import { syncAfterCrossScheduleItemMove, syncPlanPlacesAfterReorderSuccess, } from "@/lib/plan/scheduleItemPlaces"; -import { fetchAndSeedAllRoomBookmarks } from "@/lib/bookmarks/bookmark-bulk-cache"; import { appendBookmarkCategoryToCache, appendRoomBookmarksToCache, } from "@/lib/bookmarks/bookmark-cache-patch"; +import { invalidateRoomBookmarkLists } from "@/lib/bookmarks-invalidation"; import { bookmarkCategoriesQueryKey, joinRequestsQueryKey, roomAllBookmarksQueryKey, - roomBookmarksQueryKey, ROOMS_QUERY_KEY, roomDetailQueryKey, roomMembersQueryKey, @@ -96,6 +95,7 @@ import type { ScheduleTravelModeValue } from "@/lib/plan/scheduleTravelMode"; 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({ @@ -589,7 +589,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, @@ -655,10 +656,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 }); }, }); } @@ -667,8 +670,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 }); }, }); } @@ -678,10 +687,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 }), + ]), }); } @@ -695,11 +706,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 }), + ]), }); } @@ -713,9 +726,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) }), }); } @@ -750,6 +763,12 @@ export function useCreateBookmarkCategory() { onSuccess: (created, { roomId }) => { appendBookmarkCategoryToCache(queryClient, roomId, created); }, + // 응답을 이미 넣었으므로 기다리지 않는다(대화상자는 바로 닫힌다). 본인 STOMP 반향은 버려 바꾼 탭은 여기서 맞춘다 + onSettled: (_created, _error, { roomId }) => { + void queryClient.invalidateQueries({ + queryKey: bookmarkCategoriesQueryKey(roomId), + }); + }, }); } @@ -767,8 +786,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), }); }, @@ -785,11 +817,23 @@ 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), + }); +} + +/** + * 방 전체 북마크 조회 옵션. 카테고리별 목록도 이 캐시 하나에서 거른다 — 서버의 카테고리별 응답과 모양이 같고 + * 카테고리 안 순서(최근 추가 순)도 같아서, 폴더를 바꿀 때 GET·스켈레톤 없이 같은 사본을 쓴다. + */ +function roomBookmarksQueryOptions(roomId: string | null) { + return queryOptions({ + queryKey: roomAllBookmarksQueryKey(roomId), + queryFn: () => getAllRoomBookmarks(roomId!), + // 본인 변경과 STOMP 무효화로만 갱신한다. 화면 밖에서 무효화된 목록은 다시 마운트될 때 받는다 + staleTime: Infinity, }); } @@ -797,16 +841,10 @@ 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, }); } @@ -817,42 +855,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, - }); -} - -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]); - }, + select: (bookmarks) => bookmarks.filter((b) => b.categoryId === categoryId), }); } @@ -866,7 +872,10 @@ export type CreateRoomBookmarksInCategoriesResult = { bookmarks: RoomBookmark[]; }; -/** POST 한 번으로 `googlePlaceId` + `categoryIds` 배열 전달. STOMP와 함께 목록 캐시도 즉시 무효화합니다. */ +/** + * POST 한 번으로 `googlePlaceId` + `categoryIds` 배열 전달. 추가된 북마크는 응답으로 전체 목록·폴더 개수에 바로 넣고, + * 끝나면 두 목록을 서버 기준으로 다시 받습니다. 오류도 결과 객체로 돌려줘(throw 없음) 실패한 추가도 `onSettled`가 맞춥니다. + */ export function useCreateRoomBookmarksInCategories() { const queryClient = useQueryClient(); return useMutation({ @@ -929,6 +938,10 @@ export function useCreateRoomBookmarksInCategories() { ); appendRoomBookmarksToCache(queryClient, roomId, result.bookmarks); }, + // 응답을 이미 반영했으므로 기다리지 않는다(대화상자는 바로 닫힌다). 409(이미 있음)·실패도 다른 탭·사람의 추가일 수 있어 다시 받는다 + onSettled: (_result, _error, { roomId }) => { + void invalidateRoomBookmarkLists(queryClient, roomId); + }, }); } @@ -945,19 +958,17 @@ 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), - }); - 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); }, }); } @@ -970,16 +981,8 @@ export function useDeleteRoomBookmarkItem() { bookmarkId: number; categoryId: number; }) => deleteRoomBookmark(input.roomId, input.bookmarkId), - onSuccess: (_, { roomId, categoryId }) => { - queryClient.invalidateQueries({ - queryKey: roomBookmarksQueryKey(roomId, categoryId), - }); - queryClient.invalidateQueries({ - queryKey: bookmarkCategoriesQueryKey(roomId), - }); - queryClient.invalidateQueries({ - queryKey: roomAllBookmarksQueryKey(roomId), - }); - }, + // 응답 본문이 없어 목록에서 빠질 때까지 기다린다 — 확인창이 닫힌 뒤 지운 장소가 잠깐 남지 않게 + onSettled: (_data, _error, { roomId }) => + invalidateRoomBookmarkLists(queryClient, roomId), }); } diff --git a/src/lib/api/rooms/bookmarks.ts b/src/lib/api/rooms/bookmarks.ts index e9d3c289..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[] { @@ -32,18 +31,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, @@ -89,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/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/index.ts b/src/lib/api/rooms/index.ts index 5c9eca92..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,11 +32,9 @@ export { } from "./bookmark-categories"; export { - createRoomBookmark, createRoomBookmarks, deleteRoomBookmark, getAllRoomBookmarks, - getRoomBookmarks, patchRoomBookmarkCategory, } from "./bookmarks"; 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(); +}); 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); 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; 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 75c5825a..f9cbfb50 100644 --- a/src/lib/bookmarks-invalidation.ts +++ b/src/lib/bookmarks-invalidation.ts @@ -1,28 +1,31 @@ import type { QueryClient } from "@tanstack/react-query"; +import { invalidateAfterChange } from "@/lib/query-client"; import { bookmarkCategoriesQueryKey, roomAllBookmarksQueryKey, - roomBookmarksByRoomRootQueryKey, - roomBookmarksQueryKey, } from "@/lib/query-keys"; import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp"; -async function invalidateAllRoomBookmarkQueries( +/** + * 폴더 목록(장소 개수 포함)과 방 전체 북마크를 함께 무효화합니다. 북마크가 바뀌는 STOMP 이벤트와 + * 본인 변경(mutation `onSettled`)이 같이 씁니다. 받는 중인 옛 조회는 첫 조회라도 취소하고 새로 받습니다. + */ +export async function invalidateRoomBookmarkLists( queryClient: QueryClient, roomId: string, ): Promise { - await queryClient.invalidateQueries({ - queryKey: roomAllBookmarksQueryKey(roomId), - refetchType: "active", - }); - await queryClient.invalidateQueries({ - queryKey: roomBookmarksByRoomRootQueryKey(roomId), - refetchType: "active", - }); + const rid = roomId.trim(); + await Promise.all([ + invalidateAfterChange(queryClient, { queryKey: bookmarkCategoriesQueryKey(rid) }), + invalidateAfterChange(queryClient, { queryKey: roomAllBookmarksQueryKey(rid) }), + ]); } -/** STOMP `RoomBookmarkChangedEvent.type`별 최소 범위 무효화·제거만 수행합니다. */ +/** + * STOMP `RoomBookmarkChangedEvent.type`별로 바뀐 범위를 함께 무효화합니다. 화면에 있는 목록만 바로 다시 받고 + * 나머지는 다시 볼 때 받습니다. 카테고리별 북마크는 방 전체 목록에서 거르므로 전체 목록 하나로 충분합니다. + */ export async function invalidateRoomBookmarkQueries( queryClient: QueryClient, event: RoomBookmarkChangedEvent, @@ -30,61 +33,19 @@ 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), - refetchType: "active", - }); - break; - case "BOOKMARK_UPDATED": - await queryClient.invalidateQueries({ - queryKey: roomBookmarksQueryKey(rid, categoryId), - refetchType: "active", - }); - await invalidateAllRoomBookmarkQueries(queryClient, rid); - await queryClient.invalidateQueries({ - queryKey: bookmarkCategoriesQueryKey(rid), - refetchType: "active", - }); - break; - case "BOOKMARK_DELETED": - await queryClient.invalidateQueries({ - queryKey: roomBookmarksQueryKey(rid, categoryId), - refetchType: "active", - }); - 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": - queryClient.removeQueries({ - queryKey: roomBookmarksQueryKey(rid, categoryId), - }); - await invalidateAllRoomBookmarkQueries(queryClient, rid); - await queryClient.invalidateQueries({ + await invalidateAfterChange(queryClient, { queryKey: bookmarkCategoriesQueryKey(rid), - refetchType: "active", }); break; 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..1356e293 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( @@ -18,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, @@ -30,11 +30,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 +43,7 @@ function mergeBookmarksById( return [...byId.values()]; } +/** 추가한 북마크를 방 전체 목록에 합친다. 카테고리별 목록은 이 목록에서 거르므로 따로 쓰지 않는다 */ export function appendRoomBookmarksToCache( queryClient: QueryClient, roomId: string, @@ -53,22 +54,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/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/expenses/expense-recovery.test.ts b/src/lib/expenses/expense-recovery.test.ts index 59173a74..21d5f956 100644 --- a/src/lib/expenses/expense-recovery.test.ts +++ b/src/lib/expenses/expense-recovery.test.ts @@ -1,10 +1,10 @@ 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"; 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"), @@ -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,34 @@ 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: unknown) => 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]!({ 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 () => []); + 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 +149,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 +170,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 +181,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 +248,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 +305,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 +323,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"); @@ -280,10 +343,9 @@ 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 = 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"); @@ -291,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"); @@ -301,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 14e0d94a..9c82ebea 100644 --- a/src/lib/expenses/expense-recovery.ts +++ b/src/lib/expenses/expense-recovery.ts @@ -1,16 +1,19 @@ -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 { invalidateAfterChange } from "@/lib/query-client"; 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 +76,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 +118,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 +142,75 @@ 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)); + // 보이던 장부를 두고 다시 받는다(refreshing): 탭 복귀·변경 알림·본인 저장 뒤. 금액을 '—'로 바꾸지 않는다(AGENTS.md 로딩 원칙). + // 입장·연결 복구·재시도(all)와 아직 맞춰지지 않은 장부(pending·error)는 처음부터 맞춘다(pending) + const background = + scope !== "all" && (this.status === "ready" || this.status === "refreshing"); 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]; + try { + // summary-krw는 접두로 필터별 스냅샷도 함께 무효화한다(보는 필터만 다시 받는다). + // 변경 알림·본인 저장 뒤에는 받는 중인 첫 조회도 취소하고 새로 받는다 + await (restartFirstLoads + ? invalidateAfterChange(this.client, { queryKey }, { throwOnError: true }) + : this.client.invalidateQueries({ queryKey }, { throwOnError: true })); + this.failed.delete(key); + } catch (error) { + // 더 새 조회로 바뀌었거나 화면이 사라져 취소된 조회는 실패가 아니다 + if (isCancelledError(error)) return; + this.failed.add(key); + throw error; } - } - if (this.status !== "revoked") - this.setStatus(this.failed.size ? "error" : "ready"); - if (failure) throw failure; + }), + ); + for (const result of results) + if (result.status === "rejected") { + failure ??= result.reason; + this.handleError(result.reason); + } + if (failure !== undefined) throw failure; } } 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/query-keys.ts b/src/lib/query-keys.ts index e63a7b29..3be6f335 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/members-dispatch.test.ts b/src/lib/stomp/members-dispatch.test.ts index c915d8fb..4be2c067 100644 --- a/src/lib/stomp/members-dispatch.test.ts +++ b/src/lib/stomp/members-dispatch.test.ts @@ -1,76 +1,208 @@ -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)); + + 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("이벤트 전에 출발한 조회가 늦게 와도 그 응답으로 덮지 않는다", 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)); + await expect.poll(() => responses.length).toBe(2); + + responses[1]!({ members: [member(ME), member(2)] }); + responses[0]!({ members: [member(ME)] }); - expect(client.getQueryData(KEY)?.members.map((m) => m.userId)).toEqual([1, 2]); + await expect + .poll(() => client.getQueryData(KEY)?.members.map((m) => m.userId)) + .toEqual([ME, 2]); }); - it("keeps the pending refetch of a list invalidated while off screen", async () => { - const client = clientWith({ members: [member(1)] }); - await client.invalidateQueries({ queryKey: KEY, refetchType: "none" }); + 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)); - dispatchRoomPresence(client, ROOM_ID, { - type: "USER_CONNECTED", - userId: 1, - roomId: ROOM_ID, - nickname: null, - profileImageUrl: null, - }); + await expect.poll(() => responses.length).toBe(2); - expect(client.getQueryState(KEY)?.isInvalidated).toBe(true); - expect(client.getQueryData(KEY)).toEqual({ members: [member(1)] }); + 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("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..694da3c1 100644 --- a/src/lib/stomp/members-dispatch.tsx +++ b/src/lib/stomp/members-dispatch.tsx @@ -4,72 +4,31 @@ 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 { invalidateAfterChange } from "@/lib/query-client"; 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 한 건 — 바뀐 멤버 목록을 서버에서 다시 받게 한다. 이벤트 내용으로 캐시를 고치지 않는다: + * 이벤트 전에 출발한 조회는 첫 조회라도 취소하고 새로 받는다(`invalidateAfterChange` — 패치는 그 응답에 덮였다). + * 화면에 있는 목록만 바로 다시 받고 나머지는 다시 볼 때 받는다. + * 채팅 발신자 이름은 `room-members`에서 찾으므로, 채팅은 새 멤버의 메시지를 이 목록을 다시 받을 때까지 그리지 않는다(`useChatMessages`). */ export function dispatchRoomMemberEvent( queryClient: QueryClient, @@ -77,129 +36,15 @@ export function dispatchRoomMemberEvent( event: RoomMemberPayload, ): void { const rid = String(event.roomId ?? "").trim() || subscribedRoomId; - if (!rid) return; - void queryClient.invalidateQueries({ queryKey: expenseKeys.members(rid) }); - - // 무효화돼 다시 받을 목록은 패치하지 않는다 — setQueryData가 '갱신 필요' 표시를 지워 재조회가 사라진다. - // 방 목록 역할(HOST_DELEGATED)은 따로 맞춘다 - const membersAwaitingRefetch = - queryClient.getQueryState(roomMembersQueryKey(rid))?.isInvalidated === true; - - switch (event.type) { - case "MEMBER_JOINED": { - const d = event.detail; - if (!d || 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 }; - }, - ); - } + if (!rid || removesViewer(queryClient, event)) return; - patchRoomsRoleForHostDelegation( - queryClient, - rid, - d.previousHostUserId, - d.newHostUserId, - ); - break; - } + void invalidateAfterChange(queryClient, { queryKey: roomMembersQueryKey(rid) }); + void invalidateAfterChange(queryClient, { queryKey: expenseKeys.members(rid) }); - default: - break; + if (event.type === "HOST_DELEGATED") { + // 방장 판정은 방 목록·방 상세·멤버 세 출처 중 하나라도 HOST면 방장으로 본다(resolveViewerIsHost). + // 하나라도 옛 역할이 남으면 이전 방장에게 "먼저 방장을 위임해 주세요"가 다시 뜬다 + void invalidateAfterChange(queryClient, { queryKey: ROOMS_QUERY_KEY }); + void invalidateAfterChange(queryClient, { queryKey: roomDetailQueryKey(rid) }); } } 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..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"; @@ -12,42 +13,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); + // 받는 중인 조회는 이 접속 변화 전에 출발했을 수 있어, 첫 조회라도 취소하고 새로 받는다 + if (state?.fetchStatus === "fetching") { + void invalidateAfterChange(queryClient, { 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, @@ -113,19 +116,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); -} 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), ]) {