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 });
+ });
+ 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