From 31c72b20d104c7e840c741755613d2553986b56b Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Tue, 9 Jun 2026 17:56:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(career):=20=E6=9C=AA=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E6=99=82=E3=81=AE=E9=9B=A2=E8=84=B1=E7=A2=BA=E8=AA=8D=EF=BC=88?= =?UTF-8?q?=C3=97=20=E9=96=89=E3=81=98/=E3=83=AA=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=89=EF=BC=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ログイン済みで未保存の変更がある状態で × 閉じ・リロードした際、 ブラウザ標準の離脱確認ダイアログを出して編集内容の消失を防ぐ。 formCache は redux-persist の blacklist のためリロードで消えることへの対策。 - useUnsavedChangesWarning フックを追加(beforeunload ガード) - CareerResumeForm で isAuthenticated && dirty.any のとき有効化 - 未ログインは sessionStorage 退避で対象外、アプリ内遷移は Redux 保持で対象外 Co-Authored-By: Claude Opus 4.8 --- .../src/components/forms/CareerResumeForm.tsx | 8 ++++ .../hooks/useUnsavedChangesWarning.test.ts | 39 +++++++++++++++++++ .../src/hooks/useUnsavedChangesWarning.ts | 26 +++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 frontend/src/hooks/useUnsavedChangesWarning.test.ts create mode 100644 frontend/src/hooks/useUnsavedChangesWarning.ts 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]); +}