Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend/src/components/forms/CareerResumeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 モーダルのサイドバーと
* ハイライト突合に使う。
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/hooks/useUnsavedChangesWarning.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 26 additions & 0 deletions frontend/src/hooks/useUnsavedChangesWarning.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading