Skip to content

Commit 690baae

Browse files
committed
fix: 페이지 이탈 시 미저장된 데이터 감지 로직
1 parent 166e1b4 commit 690baae

File tree

4 files changed

+68
-0
lines changed

4 files changed

+68
-0
lines changed

frontend/src/app/(sidebar)/dashboard/_constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export {
3131
EXPENSES_ERROR_MESSAGE,
3232
SAVE_ERROR_MESSAGE,
3333
SAVE_VALIDATION_ERROR_MESSAGE,
34+
UNSAVED_CHANGE_WARNING_MESSAGE,
3435
} from "@/app/(sidebar)/dashboard/_constants/messages";
3536

3637
export { AUTO_CATEGORIZE_DEBOUNCE_MS } from "@/app/(sidebar)/dashboard/_constants/expenseCategory";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export const EXPENSES_ERROR_MESSAGE = "소비 내역을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.";
22
export const SAVE_ERROR_MESSAGE = "소비 내역을 저장하지 못했어요. 잠시 후 다시 시도해 주세요.";
33
export const SAVE_VALIDATION_ERROR_MESSAGE = "날짜, 사용내역, 비용, 항목을 모두 입력해 주세요.";
4+
export const UNSAVED_CHANGE_WARNING_MESSAGE = "저장되지 않은 변경사항이 있습니다. 페이지를 떠나시겠습니까?";

frontend/src/app/(sidebar)/dashboard/_hooks/useExpenseTable.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useExpenseRowSave } from "@/app/(sidebar)/dashboard/_hooks/useExpenseRo
88
import { useExpenseCellPopup } from "@/app/(sidebar)/dashboard/_hooks/useExpenseCellPopup";
99
import { useExpenseTableColumns } from "@/app/(sidebar)/dashboard/_hooks/useExpenseTableColumns";
1010
import { useExpenseTableSelection } from "@/app/(sidebar)/dashboard/_hooks/useExpenseTableSelection";
11+
import { useUnsavedChangesWarning } from "@/app/(sidebar)/dashboard/_hooks/useUnsavedChangesWarning";
1112
import { isNewRow } from "@/app/(sidebar)/dashboard/_utils";
1213
import type {
1314
EditableExpenseRow,
@@ -52,6 +53,8 @@ export const useExpenseTable = ({
5253
costDelta,
5354
} = useExpenseRowsState(initialData, resetKey);
5455

56+
useUnsavedChangesWarning(hasUnsavedChanges);
57+
5558
// 서버에서 정렬된 데이터를 받으므로 새 행(isNew)만 맨 아래 고정하고 나머지는 그대로 유지
5659
// 사용자가 새로 입력하는 행은 서버 정렬 대상이 아니므로, 정렬 결과에서 제외한다.
5760
const sortedRows = useMemo(() => {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { UNSAVED_CHANGE_WARNING_MESSAGE } from "@/app/(sidebar)/dashboard/_constants/messages";
5+
6+
/**
7+
* 미저장 변경사항이 있을 때 페이지 이탈을 경고하는 훅
8+
* - 브라우저 새로고침/탭 닫기: beforeunload
9+
* - 클라이언트 라우팅(Link 클릭): anchor click 인터셉트
10+
* - 브라우저 뒤로가기/앞으로가기: popstate 인터셉트
11+
*/
12+
export function useUnsavedChangesWarning(hasUnsavedChanges: boolean) {
13+
// 브라우저 새로고침 / 탭 닫기
14+
useEffect(() => {
15+
if (!hasUnsavedChanges) return;
16+
17+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
18+
e.preventDefault();
19+
};
20+
21+
window.addEventListener("beforeunload", handleBeforeUnload);
22+
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
23+
}, [hasUnsavedChanges]);
24+
25+
// Next.js Link 클릭 (클라이언트 라우팅) 인터셉트
26+
useEffect(() => {
27+
if (!hasUnsavedChanges) return;
28+
29+
const handleClick = (e: MouseEvent) => {
30+
const anchor = (e.target as HTMLElement).closest("a");
31+
if (!anchor) return;
32+
33+
const href = anchor.getAttribute("href");
34+
if (!href || href.startsWith("#")) return;
35+
36+
// 외부 링크는 beforeunload가 처리
37+
if (anchor.target === "_blank" || anchor.origin !== window.location.origin) return;
38+
39+
if (!window.confirm(UNSAVED_CHANGE_WARNING_MESSAGE)) {
40+
e.preventDefault();
41+
e.stopPropagation();
42+
}
43+
};
44+
45+
document.addEventListener("click", handleClick, true);
46+
return () => document.removeEventListener("click", handleClick, true);
47+
}, [hasUnsavedChanges]);
48+
49+
// 브라우저 뒤로가기 / 앞으로가기
50+
useEffect(() => {
51+
if (!hasUnsavedChanges) return;
52+
53+
const handlePopState = () => {
54+
if (!window.confirm(UNSAVED_CHANGE_WARNING_MESSAGE)) {
55+
window.history.pushState(null, "", window.location.href);
56+
}
57+
};
58+
59+
window.history.pushState(null, "", window.location.href);
60+
window.addEventListener("popstate", handlePopState);
61+
return () => window.removeEventListener("popstate", handlePopState);
62+
}, [hasUnsavedChanges]);
63+
}

0 commit comments

Comments
 (0)