Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a6066f0
refactor(bookmarks): 카테고리별 북마크 목록을 전체 목록에서 거른다
KangShinGyu98 Nov 6, 2026
81f2d1a
fix(bookmarks): STOMP 북마크 무효화를 병렬로 하고 겹치는 무효화를 없앤다
KangShinGyu98 Nov 6, 2026
5d7c525
fix(bookmarks): 폴더 삭제 뒤 전체 북마크와 폴더 목록을 다시 받는다
KangShinGyu98 Nov 6, 2026
85279ee
fix(bookmarks): 여러 폴더에 북마크 추가가 실패해도 목록을 다시 맞춘다
KangShinGyu98 Nov 6, 2026
e7b9cb6
chore(bookmarks): 호출하지 않는 단건 북마크 추가 훅을 지운다
KangShinGyu98 Nov 6, 2026
1e00b6d
refactor(bookmarks): 북마크·폴더 변경 뒤 onSettled에서 서버 기준으로 맞춘다
KangShinGyu98 Nov 6, 2026
c2b4aae
test(bookmarks): 폴더별 북마크가 전체 목록 한 번을 걸러 쓰는지 확인한다
KangShinGyu98 Nov 8, 2026
eb2b0f7
fix(members): 멤버 이벤트로 목록을 패치하지 않고 서버에서 다시 받는다
KangShinGyu98 Nov 6, 2026
359c199
fix(presence): 받는 중인 멤버 목록이 접속 상태 패치를 덮지 않게 한다
KangShinGyu98 Nov 6, 2026
ce09a9c
chore(presence): 쓰지 않는 syncRoomMembersIfCacheEmpty를 지운다
KangShinGyu98 Nov 6, 2026
72cc594
refactor(members): 멤버·입장 요청 변경 뒤 onSettled에서 서버 기준으로 맞춘다
KangShinGyu98 Nov 6, 2026
b28b6d9
fix(members): 방을 나가면 나간 방 캐시를 다시 받지 않고 바로 지운다
KangShinGyu98 Nov 6, 2026
798389d
perf(members): 멤버 목록 조회가 취소 signal을 받는다
KangShinGyu98 Nov 8, 2026
25c8c81
refactor(expenses): 가계부 조회가 TanStack의 취소 signal을 받는다
KangShinGyu98 Nov 6, 2026
32c6e7b
refactor(expenses): STOMP·복구 조회를 무효화로 바꾼다
KangShinGyu98 Nov 6, 2026
dfe8415
test(expenses): 변경 전에 출발한 첫 조회의 늦은 응답이 새 값을 덮지 않는지 확인한다
KangShinGyu98 Nov 8, 2026
1268934
fix(expenses): 변경 알림·본인 저장 뒤 다시 받는 동안 예산·합계를 비우지 않는다
KangShinGyu98 Nov 6, 2026
4d1d508
perf(expenses): 재마운트와 입장 때 겹치던 가계부 조회를 없앤다
KangShinGyu98 Nov 6, 2026
121a2e1
docs(agents): 아직 받지 못한 값은 이미지는 스켈레톤, 텍스트는 비워 둔다
KangShinGyu98 Nov 8, 2026
5cc5390
fix(chat): 이름을 아직 모르는 새 멤버의 메시지는 멤버 목록을 받을 때까지 그리지 않는다
KangShinGyu98 Nov 8, 2026
ec77ef9
fix(members): 첫 멤버 목록을 받는 중에 온 변경도 그 조회를 버리고 새로 받는다
KangShinGyu98 Nov 9, 2026
f84adb3
refactor(stomp): 북마크·가계부의 변경 뒤 재조회도 같은 취소 후 새로 받기를 쓴다
KangShinGyu98 Nov 9, 2026
7b01704
fix(chat): 멤버 목록을 처음 받기 전에는 채팅 목록 대신 로딩 표시를 둔다
KangShinGyu98 Nov 9, 2026
0d7a9e5
fix(bookmarks): 모바일에서도 폴더를 열기 전에 북마크를 미리 받는다
KangShinGyu98 Nov 9, 2026
1a2288f
test(members): 멤버 목록 조회가 취소 signal을 HTTP 요청에 넘기는지 확인한다
KangShinGyu98 Nov 9, 2026
a1e969a
test(rooms): 응답 본문 없는 변경의 재조회 대기와 실패 시 무효화를 확인한다
KangShinGyu98 Nov 9, 2026
e8bec3b
Merge remote-tracking branch 'origin/dev' into fix/stomp-cache-canonical
KangShinGyu98 Nov 9, 2026
2c69e56
test(chat): 실시간 메시지 sequence를 이어지게 해 새 멤버 이름 테스트가 실제로 화면에 붙게 한다
KangShinGyu98 Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

- 화면 전환과 데이터 조회 중에 `불러오는 중…`, `확인 중…` 같은 문장형 로딩 안내를 화면에 노출하지 않는다.
- 최초 로딩에는 완료 후 화면과 배치·크기가 비슷한 스켈레톤을 우선 사용한다. 작은 영역에는 공통 `Spinner`(`src/components/ui/spinner`) 같은 간결한 로딩 표시를 사용한다.
- 아직 받지 못한 값의 자리에서 아바타·사진 같은 이미지는 같은 크기의 스켈레톤으로 두고, 이름·문장 같은 텍스트는 아무것도 보이지 않게 둔다. `(알 수 없음)` 같은 대체 문구는 조회가 끝난 뒤에도 값이 없을 때만 쓴다. 예: 새 멤버가 입장한 직후 멤버 목록을 다시 받는 동안 입장 메시지는 보이지 않다가 `강신규님이 입장했어요`가 바로 보인다.
- 제목, 진행 현황, 버튼, 목록의 자리를 미리 확보해 로딩 전후에 화면이 튀거나 다른 화면이 잠깐 끼어 보이지 않게 한다.
- 백그라운드 재조회에서는 이미 표시된 유효한 데이터를 유지하고, 새 데이터가 준비되면 갱신한다. 재조회만으로 전체 화면을 비우거나 금액을 임시 값으로 바꾸지 않는다.
- 로딩 상태는 `role="status"`, `aria-label`, `aria-busy`, `sr-only` 등으로 접근성 도구에 전달할 수 있다. 화면에서 문구를 숨기는 것과 접근성 안내를 제거하는 것은 구분한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }) }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function AddFromBookmarkModal({
error: categoriesErr,
refetch: refetchCategories,
} = useBookmarkCategories(roomId);

// 첫 폴더가 정해지기 전에 북마크를 폴더 목록과 함께 받기 시작한다(폴더 → 북마크 순서로 기다리지 않게)
useAllRoomBookmarks(roomId);

useEffect(() => {
Expand Down
31 changes: 30 additions & 1 deletion src/components/chat/ChatPanel.inline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(<ChatPanel inline />));
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(<ChatPanel inline />));
expect(host.querySelector('[aria-label="채팅 불러오는 중"]')).toBeNull();
expect(host.textContent).toContain("여행 대화를 시작합니다");
} finally {
boundary.waitingForMembers = false;
await act(async () => root.unmount());
host.remove();
vi.unstubAllGlobals();
}
});
44 changes: 27 additions & 17 deletions src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -61,6 +62,7 @@ export function ChatPanel({

const {
messages,
waitingForMembers,
sendChatMessage,
sendAiMessage,
sendCancelAiRequest,
Expand Down Expand Up @@ -94,23 +96,31 @@ export function ChatPanel({
onClose={closeChat}
/>
) : null}
<ChatMessageList
key={rid || "no-room"}
messages={messages}
isMinimized={isMinimized}
onCancelAiRequest={sendCancelAiRequest}
onLoadOlder={fetchOlderMessages}
hasMoreOlder={hasMore}
isLoadingOlder={isFetchingOlder}
onLoadNewer={fetchNewerMessages}
hasMoreNewer={hasMoreNewer}
isLoadingNewer={isFetchingNewer}
onJumpToLatest={jumpToLatest}
readMarkerMessageId={readMarkerMessageId}
readDividerPlacement={readDividerPlacement}
scrollToAnchor={scrollToAnchor}
onAtBottom={markMessagesRead}
/>
{waitingForMembers ? (
// 보낸 사람 이름(멤버 목록)을 받기 전에는 메시지 대신 로딩 표시 — 받으면 이름과 함께 한 번에 그린다.
// 목록은 이때 처음 마운트되므로 읽음 위치 스크롤도 메시지가 다 그려진 뒤에 처리된다
<div className="flex min-h-0 flex-1 items-center justify-center bg-white">
<Spinner aria-label="채팅 불러오는 중" size={isMinimized ? 16 : 20} />
</div>
) : (
<ChatMessageList
key={rid || "no-room"}
messages={messages}
isMinimized={isMinimized}
onCancelAiRequest={sendCancelAiRequest}
onLoadOlder={fetchOlderMessages}
hasMoreOlder={hasMore}
isLoadingOlder={isFetchingOlder}
onLoadNewer={fetchNewerMessages}
hasMoreNewer={hasMoreNewer}
isLoadingNewer={isFetchingNewer}
onJumpToLatest={jumpToLatest}
readMarkerMessageId={readMarkerMessageId}
readDividerPlacement={readDividerPlacement}
scrollToAnchor={scrollToAnchor}
onAtBottom={markMessagesRead}
/>
)}
<AnimatePresence initial={false}>
{rateLimit ? (
<ChatRateLimitBanner
Expand Down
83 changes: 77 additions & 6 deletions src/components/expenses/ExpenseProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ beforeEach(() => {
});
const mocks = vi.hoisted(() => ({
members: vi.fn<() => Promise<unknown>>(() => new Promise(() => {})),
list: vi.fn<() => Promise<Expense[]>>(async () => []),
list: vi.fn<(roomId?: string, signal?: AbortSignal) => Promise<Expense[]>>(async () => []),
summary: vi.fn<() => Promise<unknown>>(async () => ({ currencies: [] })),
budget: vi.fn(async () => ({
budgetKrw: null as string | null,
Expand Down Expand Up @@ -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<typeof useExpenseContext>;
function MutationProbe() {
const value = useExpenseContext();
Expand Down Expand Up @@ -257,13 +257,82 @@ 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]);
finish({ currencies: [] });
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<void>;
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(
<QueryClientProvider client={client}>
<ExpenseProvider roomId="r"><MutationProbe /></ExpenseProvider>
</QueryClientProvider>,
);
});
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 }]);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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)); });
Expand Down
Loading
Loading