diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index 8d109f31..7657d80c 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -18,6 +18,7 @@ import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout"; import { useResumeDiffPreview } from "../../hooks/career/useResumeDiffPreview"; import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist"; import { useDocumentForm } from "../../hooks/useDocumentForm"; +import { useUnsavedChangesWarning } from "../../hooks/useUnsavedChangesWarning"; import { clearCareerDraft, loadCareerDraft, saveCareerDraft } from "../../utils/careerDraft"; import { buildCareerPayload, validateCareerForm } from "../../payloadBuilders"; import type { CareerFieldLocator, CareerFormState } from "../../payloadBuilders"; @@ -138,6 +139,13 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean /** 未保存マーク(🔴)の表示判定に使う dirty マップ */ const dirty = useCareerDirty(form, baseline); + /** + * ログイン済みで未保存の変更があるとき、× 閉じ / リロード時にブラウザ標準の離脱確認を出す。 + * formCache は redux-persist の blacklist のためリロードで消える。未保存編集の消失を防ぐ。 + * 未ログインは baseline が null で dirty.any が常に false(=対象外)。入力は sessionStorage に自動退避される。 + */ + useUnsavedChangesWarning(isAuthenticated && dirty.any); + /** * baseline(保存済み)と form(編集中)の変更点リスト。左右 diff モーダルのサイドバーと * ハイライト突合に使う。 diff --git a/frontend/src/hooks/useUnsavedChangesWarning.test.ts b/frontend/src/hooks/useUnsavedChangesWarning.test.ts new file mode 100644 index 00000000..cc156a7e --- /dev/null +++ b/frontend/src/hooks/useUnsavedChangesWarning.test.ts @@ -0,0 +1,39 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; + +import { useUnsavedChangesWarning } from "./useUnsavedChangesWarning"; + +/** beforeunload を dispatch し、ハンドラが preventDefault したか(defaultPrevented)を返す。 */ +function dispatchBeforeUnload(): boolean { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; +} + +describe("useUnsavedChangesWarning", () => { + it("when=true の間は beforeunload を抑止する(離脱確認を出す)", () => { + renderHook(() => useUnsavedChangesWarning(true)); + expect(dispatchBeforeUnload()).toBe(true); + }); + + it("when=false なら beforeunload を抑止しない(リスナ未登録)", () => { + renderHook(() => useUnsavedChangesWarning(false)); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("アンマウント後はリスナを解除する", () => { + const { unmount } = renderHook(() => useUnsavedChangesWarning(true)); + unmount(); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("when が true→false に変わるとリスナを解除する", () => { + const { rerender } = renderHook( + ({ when }: { when: boolean }) => useUnsavedChangesWarning(when), + { initialProps: { when: true } }, + ); + expect(dispatchBeforeUnload()).toBe(true); + rerender({ when: false }); + expect(dispatchBeforeUnload()).toBe(false); + }); +}); diff --git a/frontend/src/hooks/useUnsavedChangesWarning.ts b/frontend/src/hooks/useUnsavedChangesWarning.ts new file mode 100644 index 00000000..31330f6b --- /dev/null +++ b/frontend/src/hooks/useUnsavedChangesWarning.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; + +/** + * 未保存の変更があるとき、× 閉じ / リロード時にブラウザ標準の離脱確認ダイアログを出すフック。 + * + * `when` が true の間だけ `beforeunload` リスナを登録し、`preventDefault()` で + * ブラウザ標準の「このサイトを離れますか?」確認を発火させる。 + * + * 注意: + * - SPA 内のアプリ内ページ遷移では `beforeunload` は発火しない(ドキュメントの + * アンロード時=× 閉じ・リロード・別 URL への遷移時のみ)。アプリ内遷移のガードには使えない。 + * - 表示文言はブラウザが管理しており JS から上書きできない(`returnValue` の文字列は無視される)。 + */ +export function useUnsavedChangesWarning(when: boolean): void { + useEffect(() => { + if (!when) return; + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + // 一部の古いブラウザは preventDefault だけでは確認を出さないため returnValue も設定する。 + // 文字列の中身はブラウザ管理で表示には使われない(空文字で十分)。 + event.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [when]); +}