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`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다.
## 데이터 로딩
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/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]);
+}
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;
+}