From 094c3b975c00eacddd6c57a35f6a2610d9c86213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:51:00 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20history.state.idx=20=EC=9D=BD?= =?UTF-8?q?=EB=8A=94=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=ED=95=9C=EA=B3=B3?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=AA=A8=EC=9D=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-router가 내부적으로 정한 형태라 라이브러리를 올릴 때 깨질 수 있는 자리인데, ScreenLayoutRoute(전환 방향 판정)와 여러 화면의 뒤로가기가 각자 읽고 있었다. lib/historyIndex.ts 하나만 읽도록 하고 ScreenLayoutRoute를 먼저 옮긴다. --- src/app/ScreenLayoutRoute.tsx | 16 +--------------- src/lib/historyIndex.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 15 deletions(-) create mode 100644 src/lib/historyIndex.ts diff --git a/src/app/ScreenLayoutRoute.tsx b/src/app/ScreenLayoutRoute.tsx index 8266c51..0284c85 100644 --- a/src/app/ScreenLayoutRoute.tsx +++ b/src/app/ScreenLayoutRoute.tsx @@ -1,9 +1,9 @@ import { useLayoutEffect, useRef } from "react"; import { useLocation, useMatches, useNavigationType } from "react-router-dom"; - import ScreenLayout, { type ScreenBackground, } from "@/components/ui/ScreenLayout"; +import { getHistoryIndex } from "@/lib/historyIndex"; // 화면별 레이아웃 옵션 — router.tsx의 각 라우트에 `handle: { ... } satisfies ScreenRouteHandle`로 지정한다. // 모든 필드는 선택이고, 지정하지 않은 필드는 바깥 라우트의 값이나 기본값을 따른다. @@ -33,20 +33,6 @@ function resolveScreenRouteOption( return undefined; } -// createBrowserRouter는 history.state.idx에 히스토리 스택 위치를 적어 둔다(push마다 1씩 증가). -function getHistoryIndex(): number | null { - const state: unknown = window.history.state; - if ( - typeof state === "object" && - state !== null && - "idx" in state && - typeof state.idx === "number" - ) { - return state.idx; - } - return null; -} - // ScreenLayout은 라우터를 모르는 prop 기반 레이아웃으로 두고, 이 컴포넌트가 현재 라우트의 handle을 // 읽어 prop으로 넘기기만 한다. 그래서 옵션이 다른 화면이 생겨도 레이아웃 라우트를 따로 선언하지 않고, // 화면을 오가도 레이아웃이 다시 마운트되지 않는다. diff --git a/src/lib/historyIndex.ts b/src/lib/historyIndex.ts new file mode 100644 index 0000000..bf2d505 --- /dev/null +++ b/src/lib/historyIndex.ts @@ -0,0 +1,18 @@ +// createBrowserRouter는 history.state.idx에 히스토리 스택 위치를 적어 둔다(push마다 1씩 증가). +// 화면으로 바로 들어오면(딥링크·앱 WebView 진입·새로고침 후 첫 화면) 0이다. +// +// react-router 내부가 정한 형태라 라이브러리를 올릴 때 깨질 수 있는 자리다. 여러 곳에서 각자 +// 캐스팅하지 않고 여기서만 읽는다 — 읽는 쪽은 `ScreenLayoutRoute`(전환 방향 판정)와 +// `useBackNavigation`(뒤로 갈 곳이 있는지)뿐이다. +export function getHistoryIndex(): number | null { + const state: unknown = window.history.state; + if ( + typeof state === "object" && + state !== null && + "idx" in state && + typeof state.idx === "number" + ) { + return state.idx; + } + return null; +} From bc42a92e93318d2bd5d8e145fbb5c0d02c218a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:51:00 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=EB=92=A4=EB=A1=9C=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=EB=94=A5=EB=A7=81=ED=81=AC=20=ED=8F=B4=EB=B0=B1?= =?UTF-8?q?=EC=9D=84=20useBackNavigation=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면으로 바로 들어오면(딥링크·앱 WebView 진입·주소 직접 입력) 뒤에 갈 화면이 없어서 navigate(-1)이 아무 일도 하지 않는다. 이걸 막는 5줄짜리 블록이 5개 화면에 토씨 하나 다르지 않게 복붙돼 있었고, 다른 건 마지막 줄의 갈 곳뿐이었다. useBackNavigation(fallbackPath) 하나로 묶는다. 동작은 그대로다. - 검색 → "/", 알림 → "/", 알림 설정 → 알림 목록 - 행사 신청 → "/events", 열린피드백 작성 → "/feedbacks" 검색·알림 설정은 이 블록이 navigate를 쓰는 유일한 자리여서 선언과 import도 같이 지웠다. 뒤로가기 헤더 마크업(TopNavigationButton + IconChevronLeft)은 묶지 않았다. 15개 파일에 있지만 5줄짜리 JSX고, 화면마다 title·trailing 구성이 달라 훅으로 받으면 prop만 늘어난다. --- .../events/EventsApplicationScreen.tsx | 14 ++-------- src/features/feedbacks/FeedbacksNewScreen.tsx | 13 ++------- .../notifications/NotificationsScreen.tsx | 13 ++------- .../NotificationsSettingsScreen.tsx | 14 ++-------- src/features/search/SearchScreen.tsx | 16 +++-------- src/hooks/useBackNavigation.ts | 27 +++++++++++++++++++ 6 files changed, 38 insertions(+), 59 deletions(-) create mode 100644 src/hooks/useBackNavigation.ts diff --git a/src/features/events/EventsApplicationScreen.tsx b/src/features/events/EventsApplicationScreen.tsx index 93c7815..93dd536 100644 --- a/src/features/events/EventsApplicationScreen.tsx +++ b/src/features/events/EventsApplicationScreen.tsx @@ -6,7 +6,6 @@ import { import { IconChevronLeft } from "@wanteddev/wds-icon"; import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; - import ConfirmModal from "@/components/ui/ConfirmModal"; import ScreenHeader from "@/components/ui/ScreenHeader"; import ScreenToast from "@/components/ui/ScreenToast"; @@ -20,6 +19,7 @@ import { type EventsAnswer, } from "@/features/events/constants/eventsApplication"; import { submitEventsApplication } from "@/features/events/constants/eventsApplicationSubmit"; +import { useBackNavigation } from "@/hooks/useBackNavigation"; // 복수 선택은 하나라도 골라야 하고, 기타를 골랐으면 입력 내용까지 있어야 충족으로 본다 function isAnswered(answer: EventsAnswer | undefined): boolean { @@ -69,17 +69,7 @@ function EventsApplicationScreen() { // 하나라도 고르거나 쓴 게 있으면 그냥 나갔을 때 잃는 내용이 있다는 뜻이라 확인부터 받는다 const hasDraftAnswer = Object.values(answers).some(hasDraft); - // 이 화면으로 바로 들어오면(딥링크·앱 WebView 진입) 뒤로 갈 히스토리가 없어서 navigate(-1)이 - // 아무 일도 하지 않는다 — 작성 중단 모달이 열린 채로 멈춰버린다. 그럴 땐 행사 목록으로 보낸다. - // react-router가 히스토리 위치를 history.state.idx에 넣어두고, 직접 진입이면 0이다(실측 확인). - const goBack = () => { - const historyIndex = (window.history.state as { idx?: number } | null)?.idx; - if (historyIndex !== undefined && historyIndex > 0) { - navigate(-1); - return; - } - navigate("/events", { replace: true }); - }; + const goBack = useBackNavigation("/events"); const handleSubmit = async () => { setIsConfirmOpen(false); diff --git a/src/features/feedbacks/FeedbacksNewScreen.tsx b/src/features/feedbacks/FeedbacksNewScreen.tsx index 6dad8da..3e50aff 100644 --- a/src/features/feedbacks/FeedbacksNewScreen.tsx +++ b/src/features/feedbacks/FeedbacksNewScreen.tsx @@ -9,10 +9,10 @@ import { import { IconChevronLeft } from "@wanteddev/wds-icon"; import { useId, useState } from "react"; import { useNavigate } from "react-router-dom"; - import ConfirmModal from "@/components/ui/ConfirmModal"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import { useBackNavigation } from "@/hooks/useBackNavigation"; const FEEDBACKS_QUESTION_MAX_LENGTH = 500; @@ -27,16 +27,7 @@ function FeedbacksNewScreen() { const canSubmit = content.trim().length > 0; - // 이 화면으로 바로 들어오면(딥링크·새로고침) 뒤로 갈 히스토리가 없어서 navigate(-1)이 - // 아무 일도 하지 않는다 — 행사 신청 화면과 같은 이유로 그럴 땐 목록으로 보낸다. - const goBack = () => { - const historyIndex = (window.history.state as { idx?: number } | null)?.idx; - if (historyIndex !== undefined && historyIndex > 0) { - navigate(-1); - return; - } - navigate("/feedbacks", { replace: true }); - }; + const goBack = useBackNavigation("/feedbacks"); useScreenHeader( { - const historyIndex = (window.history.state as { idx?: number } | null)?.idx; - if (historyIndex !== undefined && historyIndex > 0) { - navigate(-1); - return; - } - navigate("/", { replace: true }); - }; + const goBack = useBackNavigation("/"); useScreenHeader( settings[key]); @@ -35,15 +33,7 @@ function NotificationsSettingsScreen() { setSettings((prev) => ({ ...prev, [key]: enabled })); }; - // 알림 목록을 거치지 않고 바로 들어오면(딥링크) 뒤로 갈 히스토리가 없어 알림 목록으로 보낸다. - const goBack = () => { - const historyIndex = (window.history.state as { idx?: number } | null)?.idx; - if (historyIndex !== undefined && historyIndex > 0) { - navigate(-1); - return; - } - navigate(NOTIFICATIONS_PATH, { replace: true }); - }; + const goBack = useBackNavigation(NOTIFICATIONS_PATH); useScreenHeader( { - const historyIndex = (window.history.state as { idx?: number } | null)?.idx; - if (historyIndex !== undefined && historyIndex > 0) { - navigate(-1); - return; - } - navigate("/", { replace: true }); - }; + const goBack = useBackNavigation("/"); return ( <> diff --git a/src/hooks/useBackNavigation.ts b/src/hooks/useBackNavigation.ts new file mode 100644 index 0000000..e214edb --- /dev/null +++ b/src/hooks/useBackNavigation.ts @@ -0,0 +1,27 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { getHistoryIndex } from "@/lib/historyIndex"; + +/** + * 헤더 뒤로가기 동작. 앱 안에서 타고 들어왔으면 한 단계 뒤로 가고, 이 화면으로 **바로** + * 들어왔으면(딥링크·앱 WebView 진입·주소 직접 입력) `fallbackPath`로 보낸다. + * + * 바로 들어온 경우 `navigate(-1)`은 뒤에 갈 화면이 없어서 아무 일도 하지 않는다 — 사용자 + * 입장에서는 뒤로가기 버튼이 먹통이고, 모달을 띄워 둔 화면이면 그대로 갇힌다. + * + * `fallbackPath`는 그 화면의 "한 단계 위"를 준다(알림 설정 → 알림 목록, 행사 신청 → 행사 목록). + * 되돌아올 화면이 아니라 대신 가는 곳이라 히스토리를 치환(`replace`)한다. + */ +export function useBackNavigation(fallbackPath: string) { + const navigate = useNavigate(); + + return useCallback(() => { + const historyIndex = getHistoryIndex(); + if (historyIndex !== null && historyIndex > 0) { + navigate(-1); + return; + } + navigate(fallbackPath, { replace: true }); + }, [navigate, fallbackPath]); +} From 3a70c38a46d102339f9001fe4ce6efeb3e94da95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:51:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=92=A4=EB=A1=9C=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=ED=9B=85=20=EC=82=AC=EC=9A=A9=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EC=9D=84=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index c48b62b..03c01cf 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -43,6 +43,7 @@ - `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다. - 헤더는 `useScreenHeader`로 `ScreenLayout`의 헤더 자리에 등록한다. 예외는 홈 하나다 — Figma 홈은 헤더 배경이 없어 배너 그림자가 헤더까지 번지는데, 헤더 자리에 두면 경계에서 그림자가 잘려 선이 생긴다. 그래서 홈만 스크롤 영역 안에 `sticky` 헤더를 직접 그린다(`wds-component-usage.md` 홈 섹션 참고). - 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·``으로 슬라이드 전환을 켠다. 뒤로가기는 react-router가 그 이동을 기억해 반대 방향으로 자동 적용하므로 `navigate(-1)`은 그대로 둔다. 브라우저 앞으로가기도 POP이라, `ScreenLayoutRoute`는 히스토리 위치(`history.state.idx`)가 줄어든 POP만 뒤로 방향으로 본다. Bottom Nav·상단 탭처럼 형제 화면을 오가는 이동과 홈으로 돌아가는 이동은 켜지 않는다(즉시 전환). 애니메이션은 `index.css`, 방향은 `ScreenLayoutRoute`가 정한다. +- **헤더 뒤로가기는 `useBackNavigation(fallbackPath)`를 쓴다**(`src/hooks/`). 화면으로 바로 들어오면(딥링크·앱 WebView 진입·주소 직접 입력) 뒤에 갈 화면이 없어 `navigate(-1)`이 아무 일도 하지 않는다 — 훅이 그때 `fallbackPath`(그 화면의 한 단계 위)로 `replace`해서 보낸다. `fallbackPath` 없이 `navigate(-1)`만 쓰면 그 화면은 딥링크로 열렸을 때 뒤로가기가 먹통이 된다. 히스토리 위치(`history.state.idx`)는 react-router 내부 형태라 `lib/historyIndex.ts`에서만 읽는다. - 라우트가 없는 경로는 레이아웃 안의 `path: "*"` 라우트(`ComingSoonScreen`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다. ## 데이터 로딩