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