From 9228f53741ca314c43bd453c3ad6ae42f798e32a Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 16:15:15 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix(places):=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=99=84=EC=84=B1=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=ED=95=98=EB=8A=94=20=EB=AA=A8=EB=93=A0=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EC=97=90=EC=84=9C=20=EB=8C=80=EA=B8=B0=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=A8=B8=EC=99=80=20=EB=8A=A6=EA=B2=8C=20=EC=98=A8=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=B2=84=EB=A6=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 입력창이 닫힌 뒤 대기 중인 타이머가 Google 자동완성을 한 번 더 보내던 것을 언마운트 때 취소(건별 과금) - 응답을 가리는 번호를 타이머가 실행될 때만 올려서, 보낸 뒤에 닫거나(선택·Enter) 짧게 지우거나 다시 입력하면 늦게 온 추천이 목록을 다시 열었음 - 취소(타이머 취소 + 번호 올림)를 cancelPending 하나로 모아 다시 입력·닫기·언마운트가 모두 거치게 하고, 번호는 예약할 때 잡음(지도 검색창 cancel과 같은 방식) Co-Authored-By: Claude Opus 5.5 --- .../useDebouncedPlacePredictions.test.tsx | 85 +++++++++++++++++++ src/hooks/useDebouncedPlacePredictions.ts | 30 ++++--- 2 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 src/hooks/useDebouncedPlacePredictions.test.tsx diff --git a/src/hooks/useDebouncedPlacePredictions.test.tsx b/src/hooks/useDebouncedPlacePredictions.test.tsx new file mode 100644 index 00000000..d337fee4 --- /dev/null +++ b/src/hooks/useDebouncedPlacePredictions.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { useDebouncedPlacePredictions } from "./useDebouncedPlacePredictions"; + +const sdk = vi.hoisted(() => ({ fetch: vi.fn() })); +vi.mock("@/lib/placesAutocompleteSuggest", () => ({ + AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS: 250, + fetchPlacePredictionsForRequest: sdk.fetch, + newAutocompleteSessionToken: () => ({}), +})); + +type Hook = ReturnType; +const buildRequest = (input: string) => ({ input }) as google.maps.places.AutocompleteRequest; +let api: Hook, root: Root, host: HTMLDivElement; +function Harness() { + const hook = useDebouncedPlacePredictions({ placesLibReady: true, minInputLength: 2, buildRequest }); + useEffect(() => { + api = hook; + }); + return null; +} +function deferred() { + let resolve!: (list: unknown[]) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} +const prediction = (text: string) => ({ placeId: text }) as unknown as google.maps.places.PlacePrediction; + +beforeEach(async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.useFakeTimers(); + sdk.fetch.mockReset(); + host = document.createElement("div"); + root = createRoot(host); + await act(async () => root.render()); +}); +afterEach(async () => { + await act(async () => root.unmount()); + vi.useRealTimers(); +}); + +// 예: '도쿄' 요청이 가는 중에 Enter로 검색하면, 늦게 온 '도쿄' 추천이 검색 뒤에 목록을 다시 열었다 +it("a response that arrives after the list is closed does not reopen it", async () => { + const first = deferred(); + sdk.fetch.mockReturnValueOnce(first.promise); + await act(async () => api.scheduleFetch("도쿄")); + await act(async () => vi.advanceTimersByTimeAsync(250)); + await act(async () => api.clearSuggestionUi()); + await act(async () => first.resolve([prediction("도쿄")])); + expect(api.isOpen).toBe(false); + expect(api.predictions).toEqual([]); +}); + +it("a response that arrives after the input got too short does not reopen the list", async () => { + const first = deferred(); + sdk.fetch.mockReturnValueOnce(first.promise); + await act(async () => api.scheduleFetch("도쿄")); + await act(async () => vi.advanceTimersByTimeAsync(250)); + await act(async () => api.scheduleFetch("도")); + await act(async () => first.resolve([prediction("도쿄")])); + expect(api.isOpen).toBe(false); +}); + +it("typing again ignores the previous response and shows only the new one", async () => { + const first = deferred(); + sdk.fetch.mockReturnValueOnce(first.promise).mockResolvedValueOnce([prediction("도쿄타워")]); + await act(async () => api.scheduleFetch("도쿄")); + await act(async () => vi.advanceTimersByTimeAsync(250)); + await act(async () => api.scheduleFetch("도쿄타")); + await act(async () => first.resolve([prediction("도쿄")])); + expect(api.isOpen).toBe(false); + await act(async () => vi.advanceTimersByTimeAsync(250)); + expect(api.predictions.map((p) => p.placeId)).toEqual(["도쿄타워"]); + expect(api.isOpen).toBe(true); +}); + +it("closing before the debounce fires sends no request", async () => { + await act(async () => api.scheduleFetch("도쿄")); + await act(async () => root.unmount()); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(sdk.fetch).not.toHaveBeenCalled(); + root = createRoot(host); +}); diff --git a/src/hooks/useDebouncedPlacePredictions.ts b/src/hooks/useDebouncedPlacePredictions.ts index e2c27b7b..b69f2be9 100644 --- a/src/hooks/useDebouncedPlacePredictions.ts +++ b/src/hooks/useDebouncedPlacePredictions.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AUTOCOMPLETE_SUGGEST_DEBOUNCE_MS, @@ -42,23 +42,32 @@ export function useDebouncedPlacePredictions(options: { useRef(null); const fetchGenRef = useRef(0); + // 취소 = 예약한 타이머 취소 + 이미 보낸 요청의 응답 무시(번호 올리기). 취소하는 모든 경로가 이 함수를 거친다 + const cancelPending = useCallback(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + fetchGenRef.current += 1; + }, []); + + // 입력 직후 화면이 닫히면 대기 중인 타이머가 Google 자동완성을 한 번 더 보낸다(건별 과금) + useEffect(() => cancelPending, [cancelPending]); + const invalidateSession = useCallback(() => { sessionTokenRef.current = null; }, []); const clearSuggestionUi = useCallback(() => { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - debounceRef.current = null; - } + cancelPending(); setPredictions([]); setIsOpen(false); setActiveIndex(-1); - }, []); + }, [cancelPending]); const scheduleFetch = useCallback( (value: string) => { - if (debounceRef.current) clearTimeout(debounceRef.current); + cancelPending(); if (!value.trim()) { sessionTokenRef.current = null; @@ -74,11 +83,10 @@ export function useDebouncedPlacePredictions(options: { return; } + // 번호는 예약할 때 잡는다. 그래야 예약 뒤의 취소(다시 입력·닫기·선택)를 모두 알아챈다 + const gen = fetchGenRef.current; debounceRef.current = setTimeout(() => { void (async () => { - fetchGenRef.current += 1; - const gen = fetchGenRef.current; - if (!sessionTokenRef.current) { sessionTokenRef.current = newAutocompleteSessionToken(); } @@ -103,7 +111,7 @@ export function useDebouncedPlacePredictions(options: { })(); }, debounceMs); }, - [buildRequest, debounceMs, disabled, minInputLength, placesLibReady], + [buildRequest, cancelPending, debounceMs, disabled, minInputLength, placesLibReady], ); return { From 498c900e230d0424772de5cabb2397099ffc64cb Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 3 Nov 2026 22:56:08 +0900 Subject: [PATCH 02/11] =?UTF-8?q?fix(places):=20=EB=AA=A8=EB=91=90?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=9D=84=20=EC=9E=90=EB=8F=99=EC=99=84?= =?UTF-8?q?=EC=84=B1=20=EC=B2=AB=20=ED=9B=84=EB=B3=B4=20=EB=8C=80=EC=8B=A0?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20=EC=A4=91=EC=8B=AC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 후보 좌표를 중심으로 쓰면 이름만 같은 다른 나라 장소로 지도가 넘어감(도쿄 지도 '음식점' → 용인 '음식점') - 모두검색은 첫 후보를 확정(resolve)하지 않음, 검색어에 지명이 있으면 Google이 위치 우선을 무시해 다른 도시 검색은 유지 - 쓰지 않게 된 검색 store의 fallback 제거 Co-Authored-By: Claude Opus 5.5 --- src/components/map/MobileMapSearch.tsx | 1 - src/components/map/useMapPlaceSearch.test.tsx | 6 ++- src/components/map/useMapPlaceSearch.ts | 4 +- .../search/MapPlaceSearchInput.test.tsx | 4 +- src/components/search/MapPlaceSearchInput.tsx | 7 +-- src/lib/places/place-detail-cache.test.tsx | 2 +- src/lib/places/search-session.test.ts | 22 +++++----- src/lib/places/search-session.ts | 44 +++++++++---------- src/stores/place-search-store.ts | 2 - 9 files changed, 44 insertions(+), 48 deletions(-) diff --git a/src/components/map/MobileMapSearch.tsx b/src/components/map/MobileMapSearch.tsx index a8eb89ae..35f1357b 100644 --- a/src/components/map/MobileMapSearch.tsx +++ b/src/components/map/MobileMapSearch.tsx @@ -89,7 +89,6 @@ export function MobileMapSearch({ expanded: false, status: "done", error: null, - fallback: false, termination: "not-requested", resolution: null, query: item.name, diff --git a/src/components/map/useMapPlaceSearch.test.tsx b/src/components/map/useMapPlaceSearch.test.tsx index 0eb27186..5e44f750 100644 --- a/src/components/map/useMapPlaceSearch.test.tsx +++ b/src/components/map/useMapPlaceSearch.test.tsx @@ -110,9 +110,11 @@ it("web whole search publishes only Text Search pins without selecting a detail expect(usePlaceSearchStore.getState().selectedId).toBeNull(); expect(usePlaceSearchStore.getState().items.map(x => x.googlePlaceId)).toEqual(["other"]); expect(usePlaceSearchStore.getState().items[0].rating).toBe(4.2); - expect(client.getQueryData(placeDetailQueryKey("first"))).toEqual(rawDetail); + expect(network.page).toHaveBeenCalledWith(expect.objectContaining({ latitude: 1, longitude: 2, radius: 5000 })); + expect(vi.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/places/resolve"))).toHaveLength(0); + expect(client.getQueryData(placeDetailQueryKey("first"))).toBeUndefined(); }); -it("mobile whole search keeps cards collapsed and reuses resolved full detail without GET supplement", async () => { +it("mobile whole search keeps cards collapsed without detail requests", async () => { await act(async () => root.render()); await act(async () => { await api.confirm(confirmation()); diff --git a/src/components/map/useMapPlaceSearch.ts b/src/components/map/useMapPlaceSearch.ts index 85b22916..60d4a673 100644 --- a/src/components/map/useMapPlaceSearch.ts +++ b/src/components/map/useMapPlaceSearch.ts @@ -36,6 +36,8 @@ import { usePlaceSearchStore } from "@/stores/place-search-store"; import { useSearchRecenterStore } from "@/stores/search-recenter-store"; import { useSessionStore } from "@/stores/session-store"; +const SEARCH_BIAS_RADIUS_METERS = 5000; + export function useMapPlaceSearch(mobile: boolean) { const roomId = useSessionStore((s) => s.currentRoomId); const userId = useSessionUser().data?.id; @@ -126,7 +128,6 @@ export function useMapPlaceSearch(mobile: boolean) { searchRankBuckets, selectedId, status: "done", - fallback: result.fallback, termination: result.termination, }); setActiveSearchMapPins(placeSearchResultsToMapPins(items)); @@ -186,6 +187,7 @@ export function useMapPlaceSearch(mobile: boolean) { const result = await confirmPlaceSearch({ ...confirmation, mapCenter, + radius: SEARCH_BIAS_RADIUS_METERS, isCurrent, onResolved: (resolved) => { usePlaceSearchStore.setState({ diff --git a/src/components/search/MapPlaceSearchInput.test.tsx b/src/components/search/MapPlaceSearchInput.test.tsx index 1e7c4a23..d565246e 100644 --- a/src/components/search/MapPlaceSearchInput.test.tsx +++ b/src/components/search/MapPlaceSearchInput.test.tsx @@ -69,7 +69,7 @@ async function key(key: string) { .dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })), ); } -it("puts all results first; unselected Enter retains original query and first prediction", async () => { +it("puts all results first; unselected Enter confirms the original query without a prediction", async () => { await type("원래"); await wait(); expect(host.querySelector('[role="option"]')?.textContent).toContain( @@ -77,7 +77,7 @@ it("puts all results first; unselected Enter retains original query and first pr ); await key("Enter"); expect(confirm).toHaveBeenCalledWith( - expect.objectContaining({ query: "원래", kind: "whole", prediction: p }), + expect.objectContaining({ query: "원래", kind: "whole", prediction: undefined, session: null }), ); }); it("arrow-selected recommendation Enter confirms only that prediction", async () => { diff --git a/src/components/search/MapPlaceSearchInput.tsx b/src/components/search/MapPlaceSearchInput.tsx index 8604e6b8..6b8cc48c 100644 --- a/src/components/search/MapPlaceSearchInput.tsx +++ b/src/components/search/MapPlaceSearchInput.tsx @@ -182,13 +182,14 @@ export function MapPlaceSearchInput({ suggestions?.query === value && suggestions.status === "done" ? suggestions : null; - const prediction = index > 0 ? ready?.items[index - 1] : ready?.items[0]; + // 모두검색(index ≤ 0)은 후보를 확정하지 않고 지도 중심에서 찾는다(confirmPlaceSearch) + const prediction = index > 0 ? ready?.items[index - 1] : undefined; if (index > 0 && !prediction) return; - if (ready?.session && ready.session.expiresAt <= Date.now()) { + const requestSession = prediction ? ready?.session ?? null : null; + if (requestSession && requestSession.expiresAt <= Date.now()) { token.current?.abandon(); token.current = null; schedule(value); return; } - const requestSession = ready?.session ?? null; cancel(); const requestGeneration = generation.current; confirmGeneration.current = requestGeneration; diff --git a/src/lib/places/place-detail-cache.test.tsx b/src/lib/places/place-detail-cache.test.tsx index 5438d7eb..40a8f48d 100644 --- a/src/lib/places/place-detail-cache.test.tsx +++ b/src/lib/places/place-detail-cache.test.tsx @@ -53,7 +53,7 @@ it("resolved full raw response seeds canonical cache and later panels reuse it w }; vi.mocked(requestPlaceDetail).mockReset().mockResolvedValue(detail); vi.mocked(apiFetch).mockResolvedValue(Response.json({ detail, source: "GOOGLE", cacheStatus: "MISS", googleDetailsRequested: true, termination: "SUCCEEDED" })); - await confirmPlaceSearch({ query: "카페", kind: "specific", prediction: { placeId: "one", text: null, structuredFormat: null }, session: { sessionToken: "token", expiresAt: Date.now()+60000 }, mapCenter: null, isCurrent: () => true, search: async () => [] }); + await confirmPlaceSearch({ query: "카페", kind: "specific", prediction: { placeId: "one", text: null, structuredFormat: null }, session: { sessionToken: "token", expiresAt: Date.now()+60000 }, mapCenter: null, radius: 5000, isCurrent: () => true, search: async () => [] }); const cached = client.getQueryData(placeDetailQueryKey("one")); expect(cached).toEqual(detail); expect(await fetchPlaceDetail("places/one")).toBe(cached); diff --git a/src/lib/places/search-session.test.ts b/src/lib/places/search-session.test.ts index 34a5b244..cbaf076a 100644 --- a/src/lib/places/search-session.test.ts +++ b/src/lib/places/search-session.test.ts @@ -24,18 +24,18 @@ function setup(overrides = {}) { return { search, args: { query: "원래 검색어", kind: "whole" as const, prediction: { placeId: "first", text: null, structuredFormat: null }, session: { sessionToken: "server-token", expiresAt: Date.now() + 60000 }, - mapCenter: { lat: 1, lng: 2 }, isCurrent: () => true, search, ...overrides } }; + mapCenter: { lat: 1, lng: 2 }, radius: 5000, isCurrent: () => true, search, ...overrides } }; } describe("server session confirmation", () => { it("resolves same server token once and seeds full raw detail before publishing anchor", async () => { - const { args, search } = setup(); + const { args, search } = setup({ kind: "specific" }); const onAnchor = vi.fn(() => expect(client.getQueryData(placeDetailQueryKey("first"))).toEqual(detail)); const result = await confirmPlaceSearch({ ...args, onAnchor }); expect(apiFetch).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("/places/resolve"), expect.objectContaining({ method: "POST", body: JSON.stringify({ sessionToken: "server-token", googlePlaceId: "first" }) })); expect(result?.anchor?.detail).toEqual(detail); expect(result?.termination).toBe("SUCCEEDED"); expect(onAnchor).toHaveBeenCalledTimes(1); - expect(search).toHaveBeenCalledExactlyOnceWith({ query: "원래 검색어", latitude: 37, longitude: 127, radius: 5000 }); + expect(search).not.toHaveBeenCalled(); }); it("frontend cache HIT still resolves; keeps server HIT distinct from termination", async () => { client.setQueryData(placeDetailQueryKey("first"), detail); @@ -51,15 +51,13 @@ describe("server session confirmation", () => { expect((await confirmPlaceSearch(args))?.anchor?.detail.rating).toBe(4.5); expect(search).not.toHaveBeenCalled(); }); - it.each(["absent", "failed", "null coordinates"])("whole uses map-center fallback for %s without another Google call", async mode => { - const { args, search } = setup(mode === "absent" ? { prediction: undefined } : {}); - if (mode === "failed") vi.mocked(apiFetch).mockResolvedValue(Response.json({ code: "EXTERNAL_API_ERROR", message: "실패" }, { status: 502 })); - if (mode === "null coordinates") vi.mocked(apiFetch).mockResolvedValue(Response.json({ ...response, detail: { ...detail, location: null } })); + // 도쿄 지도에서 '음식점' 첫 후보가 용인의 '음식점' 가게여도 지도 중심에서 찾는다 + it.each(["with", "without"])("whole searches around the map center %s a first suggestion and never resolves it", async mode => { + const { args, search } = setup({ radius: 1234, ...(mode === "without" ? { prediction: undefined } : {}) }); const result = await confirmPlaceSearch(args); - expect(result?.fallback).toBe(true); - expect(search).toHaveBeenCalledExactlyOnceWith({ query: "원래 검색어", latitude: 1, longitude: 2, radius: 5000 }); - expect(apiFetch).toHaveBeenCalledTimes(mode === "absent" ? 0 : 1); - if (mode === "null coordinates") expect(result?.termination).toBe("SUCCEEDED"); + expect(apiFetch).not.toHaveBeenCalled(); + expect(search).toHaveBeenCalledExactlyOnceWith({ query: "원래 검색어", latitude: 1, longitude: 2, radius: 1234 }); + expect(result).toMatchObject({ anchor: null, termination: "not-requested" }); }); it("specific null coordinates cannot fake location or make supplementary requests", async () => { vi.mocked(apiFetch).mockResolvedValue(Response.json({ ...response, detail: { ...detail, location: null } })); @@ -70,7 +68,7 @@ describe("server session confirmation", () => { it("late resolve cannot seed scope cache or publish search", async () => { let finish!: (v: Response) => void; let current = true; vi.mocked(apiFetch).mockReturnValue(new Promise(r => { finish = r; })); - const { args, search } = setup({ isCurrent: () => current }); + const { args, search } = setup({ kind: "specific", isCurrent: () => current }); const pending = confirmPlaceSearch(args); current = false; finish(Response.json(response)); expect(await pending).toBeNull(); expect(client.getQueryData(placeDetailQueryKey("first"))).toBeUndefined(); diff --git a/src/lib/places/search-session.ts b/src/lib/places/search-session.ts index f4a30ce9..3ecd88ee 100644 --- a/src/lib/places/search-session.ts +++ b/src/lib/places/search-session.ts @@ -48,6 +48,8 @@ export function terminatePredictionSession( } type ConfirmPlaceSearchArgs = Omit & { mapCenter: google.maps.LatLngLiteral | null; + /** 모두검색 locationBias 반경(m) */ + radius: number; isCurrent: () => boolean; onAnchor?: (anchor: ResolvedSearchAnchor) => void; onResolved?: (resolved: PlaceResolveResponse) => void; @@ -59,47 +61,41 @@ type SearchResolution = { }; async function resolveSearchPrediction( args: ConfirmPlaceSearchArgs, prediction: PlacePrediction, -): Promise { - let anchor: ResolvedSearchAnchor | null = null; - let termination: SearchResolution["termination"] = "not-requested"; +): Promise { try { const resolved = await terminatePredictionSession(prediction, args.session, args.isCurrent); if (!args.isCurrent()) return null; - termination = resolved.termination; // Seed the complete untouched server DTO BEFORE a detail consumer mounts. getQueryClient()?.setQueryData(placeDetailQueryKey(resolved.detail.googlePlaceId), resolved.detail); args.onResolved?.(resolved); const location = normalizePreviewLocation(resolved.detail.location); - if (location && Math.abs(location.lat) <= 90 && Math.abs(location.lng) <= 180) - anchor = { ...resolved, googlePlaceId: resolved.detail.googlePlaceId, location }; - else if (args.kind === "specific") throw new Error("장소 좌표를 확인할 수 없어요."); + if (!location || Math.abs(location.lat) > 90 || Math.abs(location.lng) > 180) + throw new Error("장소 좌표를 확인할 수 없어요."); + return { ...resolved, googlePlaceId: resolved.detail.googlePlaceId, location }; } catch (error) { - // Coordinate absence does not erase a verified server termination. - if (termination === "not-requested") termination = "failed"; - if (args.kind === "specific" && args.isCurrent()) throw error; + if (args.isCurrent()) throw error; + return null; } - return { anchor, termination }; } export async function confirmPlaceSearch(args: ConfirmPlaceSearchArgs): Promise< - SearchResolution & { items: T[]; fallback: boolean } | null + SearchResolution & { items: T[] } | null > { const query = args.query.trim(); if (!query) throw new Error("검색어를 입력해주세요."); if (!args.isCurrent()) return null; - const resolution = args.prediction - ? await resolveSearchPrediction(args, args.prediction) - : { anchor: null, termination: "not-requested" as const }; - if (!resolution) return null; - const { anchor, termination } = resolution; - if (!args.isCurrent()) return null; - if (anchor) args.onAnchor?.(anchor); if (args.kind === "specific") { - if (!anchor) throw new Error("장소 정보를 확인할 수 없어요."); - return { anchor, items: [], fallback: false, termination }; + if (!args.prediction) throw new Error("장소 정보를 확인할 수 없어요."); + const anchor = await resolveSearchPrediction(args, args.prediction); + if (!anchor || !args.isCurrent()) return null; + args.onAnchor?.(anchor); + return { anchor, items: [], termination: anchor.termination }; } - const center = anchor?.location ?? args.mapCenter; + // 모두검색은 자동완성 첫 후보가 아니라 보고 있는 지도 중심에서 찾는다. + // 첫 후보는 이름만 같은 다른 나라 장소일 수 있다(도쿄 지도에서 '음식점' → 용인의 '음식점' 가게). + // 검색어에 지명이 있으면 Google이 locationBias를 무시하므로 서울 지도에서 '라스베가스'도 찾는다. + const center = args.mapCenter; if (!center) throw new Error("지도 위치를 확인한 뒤 다시 검색해주세요."); - const items = await args.search({ query, latitude: center.lat, longitude: center.lng, radius: 5000 }); + const items = await args.search({ query, latitude: center.lat, longitude: center.lng, radius: args.radius }); if (!args.isCurrent()) return null; - return { anchor, items, fallback: !anchor, termination }; + return { anchor: null, items, termination: "not-requested" }; } diff --git a/src/stores/place-search-store.ts b/src/stores/place-search-store.ts index 96126b1a..28ca9f18 100644 --- a/src/stores/place-search-store.ts +++ b/src/stores/place-search-store.ts @@ -15,7 +15,6 @@ type PlaceSearchState = { expanded: boolean; status: "idle" | "pending" | "done" | "error"; error: string | null; - fallback: boolean; termination: PlaceResolveResponse["termination"] | "failed" | "not-requested"; resolution: Omit | null; setScope: (scope: string) => void; @@ -33,7 +32,6 @@ const empty = { expanded: false, status: "idle" as const, error: null, - fallback: false, termination: "not-requested" as const, resolution: null, }; From 6e2573b3b956d9963cc00e20b565199ffa3036b4 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 3 Nov 2026 22:56:08 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix(places):=20=ED=98=84=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=EC=97=90=EC=84=9C=20=EA=B2=80=EC=83=89=EC=9D=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=95=88=20=EA=B2=B0=EA=B3=BC=EB=A7=8C=20?= =?UTF-8?q?=EB=B3=B4=EC=97=AC=20=EC=A3=BC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Text Search 위치 우선은 영역 밖 결과도 돌려줘 재검색하면 이동 전 장소로 지도가 되돌아감 - 재검색 결과를 화면 경계로 거르고, 남는 게 없으면 결과 없음 - 재검색 위치 우선 반경을 5km 고정 대신 화면을 덮는 크기로 맞춤 - 지도 store에 화면 경계(bounds) 추가 Co-Authored-By: Claude Opus 5.5 --- src/components/map/Map.tsx | 2 ++ src/components/map/useMapPlaceSearch.test.tsx | 30 ++++++++++++++++++- src/components/map/useMapPlaceSearch.ts | 16 ++++++++-- src/stores/map-center-store.ts | 8 +++-- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 3abf7dcd..4cdf2754 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -194,6 +194,7 @@ function MapBootstrapSync({ mapCenter: { lat, lng }, zoom: mapZoom, radiusMeters, + bounds: bounds?.toJSON() ?? null, }); }, [map, ready, center.lat, center.lng, zoom, roomId, setMapCamera]); @@ -461,6 +462,7 @@ export default function TripMap({ mapCenter: { lat: center.lat, lng: center.lng }, zoom, radiusMeters, + bounds, }); const rid = diff --git a/src/components/map/useMapPlaceSearch.test.tsx b/src/components/map/useMapPlaceSearch.test.tsx index 5e44f750..e5dad701 100644 --- a/src/components/map/useMapPlaceSearch.test.tsx +++ b/src/components/map/useMapPlaceSearch.test.tsx @@ -12,6 +12,7 @@ import { registerQueryClient } from "@/lib/query-client"; import { placeDetailQueryKey } from "@/lib/places/place-queries"; import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store"; import { useSessionStore } from "@/stores/session-store"; +import { useSearchRecenterStore } from "@/stores/search-recenter-store"; const network = vi.hoisted(() => ({ page: vi.fn(), detail: vi.fn(), @@ -24,6 +25,12 @@ vi.mock("@/hooks/usePlacesSearch", () => ({ fetchPlacesPageWithPhotos: network.page, })); vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); +vi.mock("@/lib/map-viewport-commit", () => ({ + boundsInstanceFromDiscoverSnapshot: (b: google.maps.LatLngBoundsLiteral) => ({ + contains: (p: google.maps.LatLngLiteral) => + p.lat >= b.south && p.lat <= b.north && p.lng >= b.west && p.lng <= b.east, + }), +})); vi.mock("@/contexts/SelectedPlaceContext", () => ({ useSelectedPlace: () => ({ setSelectedPlace: network.select }), })); @@ -95,7 +102,7 @@ beforeEach(async () => { network.track.mockReset(); useSessionStore.setState({ currentRoomId: "room" }); usePlaceSearchStore.getState().clear(); - useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 12 }); + useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 12, radiusMeters: null, bounds: null }); host = document.createElement("div"); root = createRoot(host); await act(async () => root.render()); @@ -141,6 +148,27 @@ it("mobile specific reuses chosen resolved detail without Text Search", async () expect.objectContaining({ analyticsSource: "search", itinerarySource: "search" }), ); }); +// 센소지를 찾은 뒤 지도를 5km 넘게 옮겨 재검색하면 Google이 센소지를 다시 돌려준다 +it.each([ + ["keeps only results inside the visible map", ["inside"]], + ["shows no results when every result is outside the visible map", []], +] as const)("search here %s", async (_, expected) => { + await act(async () => { await api.confirm(confirmation()); }); + useMapCenterStore.setState({ + mapCenter: { lat: 1, lng: 2 }, zoom: 15, radiusMeters: 1234, + bounds: { south: 0, west: 1, north: 2, east: 3 }, + }); + const outside = { googlePlaceId: "outside", name: "이동 전 장소", location: { lat: 38, lng: 128 } }; + const inside = { googlePlaceId: "inside", name: "화면 안", location: { lat: 1.5, lng: 2.5 } }; + network.page.mockResolvedValueOnce({ items: expected.length ? [outside, inside] : [outside], nextPageToken: null }); + await act(async () => useSearchRecenterStore.getState().requestSearchRecenter()); + await vi.waitFor(() => + expect(usePlaceSearchStore.getState().items.map((x) => x.googlePlaceId)).toEqual(expected), + ); + expect(network.page).toHaveBeenCalledTimes(2); + expect(network.page).toHaveBeenLastCalledWith(expect.objectContaining({ query: "원래 검색어", latitude: 1, longitude: 2, radius: 1234 })); + expect(usePlaceSearchStore.getState().status).toBe("done"); +}); it("editing input suppresses a late response and analytics", async () => { let finish!: (v: unknown) => void; network.page.mockReturnValue( diff --git a/src/components/map/useMapPlaceSearch.ts b/src/components/map/useMapPlaceSearch.ts index 60d4a673..22a7fcda 100644 --- a/src/components/map/useMapPlaceSearch.ts +++ b/src/components/map/useMapPlaceSearch.ts @@ -18,9 +18,11 @@ import { type SearchRankBucket, } from "@/lib/analytics/context"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; +import { boundsInstanceFromDiscoverSnapshot } from "@/lib/map-viewport-commit"; import { getQueryClient } from "@/lib/query-client"; import { placeDetailQueryKey } from "@/lib/places/place-queries"; import { PLACES_SEARCH_PAGE_SIZE } from "@/lib/places/placesSearchPageSize"; +import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius"; import { confirmPlaceSearch, type ResolvedSearchAnchor, @@ -168,7 +170,11 @@ export function useMapPlaceSearch(mobile: boolean) { usePlaceSearchStore.getState().scope === scopeKey && useSessionStore.getState().currentRoomId === roomId && searchPinWriteStillValid(epoch); - const mapCenter = useMapCenterStore.getState().mapCenter; + const { mapCenter, radiusMeters, bounds } = useMapCenterStore.getState(); + // 「현 위치에서 검색」은 지금 화면 안 결과만 보여 준다. Text Search의 locationBias는 영역 밖 결과도 + // 돌려주므로(이동 전 장소로 지도가 되돌아감) 화면 경계로 거르고, 남는 게 없으면 결과 없음이다. + // 반경도 화면을 덮는 원으로 맞춰야 확대한 화면에서 결과가 화면 밖으로 밀려나지 않는다. + const searchArea = mode === "map_recenter" ? bounds : null; usePlaceSearchStore.setState({ query, confirmationKind: confirmation.kind, @@ -187,7 +193,9 @@ export function useMapPlaceSearch(mobile: boolean) { const result = await confirmPlaceSearch({ ...confirmation, mapCenter, - radius: SEARCH_BIAS_RADIUS_METERS, + radius: searchArea + ? clampPlacesSearchRadiusMeters(radiusMeters ?? SEARCH_BIAS_RADIUS_METERS) + : SEARCH_BIAS_RADIUS_METERS, isCurrent, onResolved: (resolved) => { usePlaceSearchStore.setState({ @@ -211,7 +219,9 @@ export function useMapPlaceSearch(mobile: boolean) { pageSize: PLACES_SEARCH_PAGE_SIZE, pageToken: undefined, }); - return page.items; + if (!searchArea) return page.items; + const area = boundsInstanceFromDiscoverSnapshot(searchArea); + return page.items.filter((item) => area.contains(item.location)); }, }); if (!result || !isCurrent()) return false; diff --git a/src/stores/map-center-store.ts b/src/stores/map-center-store.ts index 750f025f..92265c89 100644 --- a/src/stores/map-center-store.ts +++ b/src/stores/map-center-store.ts @@ -7,11 +7,14 @@ type MapCenterState = { zoom: number | null; /** 현재 뷰포트 기준 대략 반경(m): 중심 ↔ bounds 북동 코너 */ radiusMeters: number | null; + /** 현재 뷰포트 경계 — 「현 위치에서 검색」 결과를 화면 안으로 거른다 */ + bounds: google.maps.LatLngBoundsLiteral | null; setMapCenter: (center: MapCenterCoords) => void; setMapCamera: (payload: { mapCenter: MapCenterCoords; zoom: number; radiusMeters: number; + bounds: google.maps.LatLngBoundsLiteral | null; }) => void; }; @@ -19,7 +22,8 @@ export const useMapCenterStore = create((set) => ({ mapCenter: null, zoom: null, radiusMeters: null, + bounds: null, setMapCenter: (mapCenter) => set({ mapCenter }), - setMapCamera: ({ mapCenter, zoom, radiusMeters }) => - set({ mapCenter, zoom, radiusMeters }), + setMapCamera: ({ mapCenter, zoom, radiusMeters, bounds }) => + set({ mapCenter, zoom, radiusMeters, bounds }), })); From ddb2fda048e5e39c9ee6430d6fa4f816b7bb141b Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 3 Nov 2026 23:00:06 +0900 Subject: [PATCH 04/11] =?UTF-8?q?fix(map):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EB=A1=9C=20=EC=A7=80=EB=8F=84=EA=B0=80=20?= =?UTF-8?q?=EC=9B=80=EC=A7=81=EC=9D=B8=20=EB=92=A4=20'=ED=98=84=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=EC=97=90=EC=84=9C=20=EA=B2=80=EC=83=89'=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=9D=B4=20=EB=B0=94=EB=A1=9C=20=EB=9C=A8?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기준점을 검색 요청 때 화면 대신 결과로 옮긴 지도가 멈춘 화면으로 다시 잡음(맞춤 확대·결과 1개 확대·핀/카드 선택 이동) - 멈추기 전에 사용자가 지도를 누르거나 휠을 굴리면 취소해 사용자의 이동은 기준점으로 삼지 않음 Co-Authored-By: Claude Opus 5.5 --- src/components/map/Map.tsx | 10 ++++-- src/lib/map-viewport-commit.test.ts | 52 +++++++++++++++++++++++++++++ src/lib/map-viewport-commit.ts | 33 ++++++++++++++++-- 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 src/lib/map-viewport-commit.test.ts diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 4cdf2754..68e4fc64 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -39,6 +39,7 @@ import { import { MAP_DETAIL_PANEL_COVER_WIDTH_PX } from "@/lib/layout-tokens"; import { isPlanPath } from "@/lib/layout/mainChromeLayoutWidth"; import { isMobileMapPathname } from "@/lib/mobile-view"; +import { settleSearchSnapshotAfterMove } from "@/lib/map-viewport-commit"; import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius"; import { useMapCenterStore } from "@/stores/map-center-store"; import { useSearchRecenterStore } from "@/stores/search-recenter-store"; @@ -292,7 +293,9 @@ function MapSearchResultPins() { const selectedPlaceId = isMobileDevice && !selectedPlace ? searchSelectedId ?? "" : selectedPlace?.googlePlaceId?.trim() ?? ""; useEffect(() => { - if (map && preview) panToWithinWorld(map, preview.location); + if (!map || !preview) return; + panToWithinWorld(map, preview.location); + return settleSearchSnapshotAfterMove(map); }, [map, preview]); // 전체 검색 결과가 준비되면 첫 추천 대신 실제 결과들이 보이도록 맞춘다. @@ -306,6 +309,7 @@ function MapSearchResultPins() { items.forEach((item) => bounds.extend(item.location)); map.fitBounds(bounds, 80); } + return settleSearchSnapshotAfterMove(map); }, [map, isMobileDevice, pinsFocus, kind, status, items]); if (pinsFocus !== "search") return null; @@ -772,7 +776,9 @@ function MobileSearchSelection() { const lat = item?.location.lat; const lng = item?.location.lng; useEffect(() => { - if (map && focus === "search" && !selectedPlace && lat != null && lng != null) panToWithinWorld(map, { lat, lng }); + if (!map || focus !== "search" || selectedPlace || lat == null || lng == null) return; + panToWithinWorld(map, { lat, lng }); + return settleSearchSnapshotAfterMove(map); }, [map, focus, selectedPlace, selectedId, lat, lng]); if (!item || focus !== "search" || selectedPlace) return null; return event.stop()}>; diff --git a/src/lib/map-viewport-commit.test.ts b/src/lib/map-viewport-commit.test.ts new file mode 100644 index 00000000..463d2d30 --- /dev/null +++ b/src/lib/map-viewport-commit.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { beforeEach, expect, it } from "vitest"; +import { settleSearchSnapshotAfterMove } from "./map-viewport-commit"; +import { useMapCenterStore } from "@/stores/map-center-store"; +import { useSearchRecenterStore } from "@/stores/search-recenter-store"; + +const searched = { center: { lat: 0, lng: 0 }, zoom: 10, radius: 5000 }; + +function fakeMap() { + const div = document.createElement("div"); + const handlers = new Map void>(); + return { + div, + fire: (name: string) => handlers.get(name)?.(), + map: { + getDiv: () => div, + addListener: (name: string, handler: () => void) => { + handlers.set(name, handler); + return { remove: () => handlers.delete(name) }; + }, + }, + }; +} + +beforeEach(() => { + useSearchRecenterStore.setState({ searchSnapshot: searched }); + useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 14, radiusMeters: 1500 }); +}); + +// 카페 검색 결과에 맞춰 지도가 확대되면 그 화면이 기준점이라 버튼이 보이지 않는다 +it("takes the settled result camera as the search-here baseline", () => { + const { map, fire } = fakeMap(); + settleSearchSnapshotAfterMove(map); + fire("idle"); + expect(useSearchRecenterStore.getState().searchSnapshot).toEqual({ center: { lat: 1, lng: 2 }, zoom: 14, radius: 1500 }); +}); + +it.each(["pointerdown", "wheel"])("keeps the searched area when the user %s the map before it settles", (event) => { + const { map, div, fire } = fakeMap(); + settleSearchSnapshotAfterMove(map); + div.dispatchEvent(new Event(event)); + fire("idle"); + expect(useSearchRecenterStore.getState().searchSnapshot).toEqual(searched); +}); + +it("does not restore a search the user already cleared", () => { + const { map, fire } = fakeMap(); + settleSearchSnapshotAfterMove(map); + useSearchRecenterStore.getState().clearSearchSnapshot(); + fire("idle"); + expect(useSearchRecenterStore.getState().searchSnapshot).toBeNull(); +}); diff --git a/src/lib/map-viewport-commit.ts b/src/lib/map-viewport-commit.ts index cdced274..548a5b30 100644 --- a/src/lib/map-viewport-commit.ts +++ b/src/lib/map-viewport-commit.ts @@ -3,9 +3,10 @@ import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius"; import { viewportSearchRadiusMetersFromBounds } from "@/lib/maps"; import { useMapCenterStore } from "@/stores/map-center-store"; -import type { - DiscoverMapSnapshot, - SearchMapSnapshot, +import { + useSearchRecenterStore, + type DiscoverMapSnapshot, + type SearchMapSnapshot, } from "@/stores/search-recenter-store"; /** 검색 페이지 `GET /places/search` 의 위치 고정 스냅샷 (`map-center-store` 기준) */ @@ -24,6 +25,32 @@ export function buildSearchMapSnapshotFromMapCenterStore(): SearchMapSnapshot | }; } +/** + * 검색 결과를 보여 주려고 옮긴 지도가 멈추면 그 화면을 「현 위치에서 검색」 기준점으로 삼는다. + * 기준점이 검색 요청 때 화면이면 결과로 옮기자마자(맞춤 확대·첫 결과로 이동) 버튼이 다시 보인다. + * 옮길 곳이 지금 화면과 같으면 idle이 오지 않으므로, 그 전에 사용자가 지도를 만지면 취소해 + * 사용자가 옮긴 화면을 기준점으로 삼지 않는다. +/−는 지도 안 컨트롤이라 함께 잡힌다. + */ +export function settleSearchSnapshotAfterMove( + map: Pick, +): () => void { + const div = map.getDiv(); + const cancel = () => { + idle.remove(); + div.removeEventListener("pointerdown", cancel, true); + div.removeEventListener("wheel", cancel, true); + }; + const idle = map.addListener("idle", () => { + cancel(); + const camera = buildSearchMapSnapshotFromMapCenterStore(); + const { searchSnapshot, setSearchSnapshot } = useSearchRecenterStore.getState(); + if (camera && searchSnapshot) setSearchSnapshot(camera); + }); + div.addEventListener("pointerdown", cancel, true); + div.addEventListener("wheel", cancel, { capture: true, passive: true }); + return cancel; +} + /** Discover(Places JS) 영역 고정 스냅샷 — 맵 현재 카메라 기준 1회 커밋 */ export function buildDiscoverSnapshotFromGoogleMap( map: google.maps.Map, From 33de1fdc6d7d9349e835e0f1e25c70cf89efe327 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 16:15:56 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix(map):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=95=88=EB=82=B4=EB=A5=BC=20'=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=97=90=20=EC=8B=A4=ED=8C=A8=ED=96=88=EC=96=B4?= =?UTF-8?q?=EC=9A=94.'=EC=99=80=20=EB=8B=A4=EC=8B=9C=20=EC=8B=9C=EB=8F=84?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모두검색이 실패하면 오류 원문("Failed to fetch") 대신 '검색에 실패했어요.'를 보여 줌 - 글자 링크 '다시 시도'를 오른쪽 끝 다시 시도 아이콘 버튼(누르는 영역 44px)으로 바꿈 - 후보 클릭 실패 문구는 이번 범위에서 제외(그대로) Co-Authored-By: Claude Opus 5.5 --- src/components/map/MapPlaceSearch.test.tsx | 4 +--- src/components/map/MapPlaceSearch.tsx | 11 +++++++---- src/components/map/useMapPlaceSearch.test.tsx | 5 +++++ src/components/map/useMapPlaceSearch.ts | 11 +++++++---- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/components/map/MapPlaceSearch.test.tsx b/src/components/map/MapPlaceSearch.test.tsx index 63a13fd2..70504a28 100644 --- a/src/components/map/MapPlaceSearch.test.tsx +++ b/src/components/map/MapPlaceSearch.test.tsx @@ -84,9 +84,7 @@ it("specific Details failure retry returns to that input with a fresh session an (buttons[1] as HTMLButtonElement).click(); }); expect(usePlaceSearchStore.getState().status).toBe("error"); - const retry = [...host.querySelectorAll("button")].find( - (button) => button.textContent === "다시 시도", - )!; + const retry = host.querySelector('button[aria-label="다시 시도"]')!; await act(async () => retry.click()); expect(boundary.page).not.toHaveBeenCalled(); expect(document.activeElement).toBe(input); diff --git a/src/components/map/MapPlaceSearch.tsx b/src/components/map/MapPlaceSearch.tsx index 32954c3e..49607da3 100644 --- a/src/components/map/MapPlaceSearch.tsx +++ b/src/components/map/MapPlaceSearch.tsx @@ -2,6 +2,7 @@ import { useRef } from "react"; import { flushSync } from "react-dom"; +import { RefreshIcon } from "@/assets/icons"; import { useMobileMapSearchStore } from "@/stores/mobile-map-search-store"; import { useSearchParams } from "next/navigation"; import { MapPlaceSearchInput } from "@/components/search/MapPlaceSearchInput"; @@ -103,10 +104,12 @@ function SearchFeedback({ status, error, hasItems, retry }: Readonly<{ }>) { if (status === "pending") return ; if (status === "error") return ( -
- {error} -
); diff --git a/src/components/map/useMapPlaceSearch.test.tsx b/src/components/map/useMapPlaceSearch.test.tsx index e5dad701..dcf48150 100644 --- a/src/components/map/useMapPlaceSearch.test.tsx +++ b/src/components/map/useMapPlaceSearch.test.tsx @@ -169,6 +169,11 @@ it.each([ expect(network.page).toHaveBeenLastCalledWith(expect.objectContaining({ query: "원래 검색어", latitude: 1, longitude: 2, radius: 1234 })); expect(usePlaceSearchStore.getState().status).toBe("done"); }); +it("search box failure shows one message instead of the raw error", async () => { + network.page.mockRejectedValueOnce(new TypeError("Failed to fetch")); + await act(async () => { await api.confirm(confirmation()); }); + expect(usePlaceSearchStore.getState()).toMatchObject({ status: "error", error: "검색에 실패했어요." }); +}); it("editing input suppresses a late response and analytics", async () => { let finish!: (v: unknown) => void; network.page.mockReturnValue( diff --git a/src/components/map/useMapPlaceSearch.ts b/src/components/map/useMapPlaceSearch.ts index 22a7fcda..d9cfd087 100644 --- a/src/components/map/useMapPlaceSearch.ts +++ b/src/components/map/useMapPlaceSearch.ts @@ -232,10 +232,7 @@ export function useMapPlaceSearch(mobile: boolean) { usePlaceSearchStore.setState({ status: "error", termination: failedTermination(confirmation), - error: - error instanceof Error - ? error.message - : "검색에 실패했어요. 다시 시도해주세요.", + error: searchFailureMessage(confirmation, error), }); return false; } @@ -287,6 +284,12 @@ function toAnchor( }; } +// 모두검색 실패의 원문(네트워크 오류면 "Failed to fetch")은 사용자에게 뜻이 없어 한 문구로 보여 준다 +function searchFailureMessage(confirmation: SearchConfirmation, error: unknown) { + if (confirmation.kind === "whole") return "검색에 실패했어요."; + return error instanceof Error ? error.message : "검색에 실패했어요. 다시 시도해주세요."; +} + function failedTermination(confirmation: SearchConfirmation) { const termination = usePlaceSearchStore.getState().termination; if (termination === "SUCCEEDED" || termination === "NOT_TERMINATED") return termination; From 9ce3c5f73348bfa4d7622e920a8a2e6a1dedd1a6 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 16:16:20 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix(places):=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=99=84=EC=84=B1=20=EC=8B=A4=ED=8C=A8=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=EC=97=90=20=EC=98=A4=EB=A5=98=20=EC=9B=90=EB=AC=B8=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EA=B8=B0=EB=B3=B8=20=EC=95=88=EB=82=B4=20=EB=B3=B4?= =?UTF-8?q?=EC=97=AC=20=EC=A3=BC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추천을 못 받으면 '모든 결과보기' 아래 안내에 "Failed to fetch" 같은 원문이 나오던 것을 기본 안내("검색 추천을 불러오지 못했어요. 다시 입력해주세요.")로 바꿈 - 서버가 준 안내(429 "잠시 후 다시 검색해주세요." 등)는 그대로 보여 줌 Co-Authored-By: Claude Opus 5.5 --- src/components/search/MapPlaceSearchInput.test.tsx | 8 ++++++++ src/components/search/MapPlaceSearchInput.tsx | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/search/MapPlaceSearchInput.test.tsx b/src/components/search/MapPlaceSearchInput.test.tsx index d565246e..525c6084 100644 --- a/src/components/search/MapPlaceSearchInput.test.tsx +++ b/src/components/search/MapPlaceSearchInput.test.tsx @@ -167,6 +167,14 @@ it("429 respects shared wait without request loop on further input", async () => expect(sdk.fetch).toHaveBeenCalledTimes(1); expect(host.textContent).toContain("잠시 후"); }); +it("network failure shows the default notice under all results instead of the raw error", async () => { + sdk.fetch.mockRejectedValue(new TypeError("Failed to fetch")); + await type("원래"); await wait(); + expect([...host.querySelectorAll('[role="option"]')].map((option) => option.textContent)) + .toEqual(["'원래' 모든 결과보기"]); + expect(host.textContent).toContain("검색 추천을 불러오지 못했어요. 다시 입력해주세요."); + expect(host.textContent).not.toContain("Failed to fetch"); +}); it("nullable structured main text falls back to actual complete prediction text", async () => { await act(async () => vi.advanceTimersByTimeAsync(61000)); sdk.fetch.mockImplementation(async url => String(url).endsWith("/places/sessions") diff --git a/src/components/search/MapPlaceSearchInput.tsx b/src/components/search/MapPlaceSearchInput.tsx index 6b8cc48c..b4704f0c 100644 --- a/src/components/search/MapPlaceSearchInput.tsx +++ b/src/components/search/MapPlaceSearchInput.tsx @@ -13,7 +13,7 @@ import { MapSearchIcon } from "@/components/map/MapSearchIcon"; import { SearchIcon } from "@/assets/icons"; import { SearchBar, SearchBarBack, SearchBarClear, SearchBarIcon, SearchBarInput } from "@/components/ui/search-bar"; import { AutocompleteSearchSession, MAP_AUTOCOMPLETE_DEBOUNCE_MS } from "@/lib/places/autocomplete-session"; -import type { PlacePrediction, PlaceSession, PredictionText } from "@/lib/api/place-sessions"; +import { PlaceSessionError, type PlacePrediction, type PlaceSession, type PredictionText } from "@/lib/api/place-sessions"; import type { SearchConfirmation } from "@/lib/places/search-session"; import { useMapCenterStore } from "@/stores/map-center-store"; import { cn } from "@/lib/utils"; @@ -145,7 +145,8 @@ export function MapPlaceSearchInput({ if (requestGeneration === generation.current) { flow.abandon(); token.current = null; - setSuggestions({ query, items: [], session: null, status: "error", error: error instanceof Error ? error.message : undefined }); + // 서버가 준 안내(429 등)는 그대로, 네트워크 오류 원문("Failed to fetch")은 뜻이 없어 기본 안내로 보여 준다 + setSuggestions({ query, items: [], session: null, status: "error", error: error instanceof PlaceSessionError ? error.message : undefined }); } }, ); From 984d78d585a1c8139b0a7fcd6e757ecf00010e1e Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 14:15:45 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix(map):=20=EA=B2=80=EC=83=89=EC=B0=BD?= =?UTF-8?q?=EC=97=90=20=EB=8B=A4=EC=8B=9C=20=EB=93=A4=EC=96=B4=EA=B0=80?= =?UTF-8?q?=EB=A9=B4=20=EA=B2=B0=EA=B3=BC=20=EC=97=86=EC=9D=8C=C2=B7?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=95=88=EB=82=B4=EB=A5=BC=20=EC=88=A8?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 안내 상자와 추천 목록이 둘 다 검색창 바로 아래라, 다시 검색하려고 누르면 안내가 추천 목록을 덮음 - 검색창 입력에 포커스가 있는 동안 안내 상자를 숨김(CSS group-has, 상태 추가 없음) Co-Authored-By: Claude Opus 5.5 --- src/components/map/MapPlaceSearch.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/map/MapPlaceSearch.tsx b/src/components/map/MapPlaceSearch.tsx index 49607da3..ea55aeb3 100644 --- a/src/components/map/MapPlaceSearch.tsx +++ b/src/components/map/MapPlaceSearch.tsx @@ -64,7 +64,7 @@ export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{