フロントエンドエラーハンドリング
Error Boundary 3層構造
React の Error Boundary を3層に配置し、エラーの影響範囲を最小化する:
| 層 | 配置場所 | 捕捉範囲 | フォールバック UI |
|---|---|---|---|
| L1: アプリケーション | ルートレイアウト(各ポータルのエントリポイント) | アプリ全体のクラッシュ | 「予期しないエラーが発生しました」画面 + TraceId 表示 + リロードボタン |
| L2: ルート | TanStack Router の各ルート定義 | 画面単位のエラー | 該当画面のみエラー表示。サイドバー・ナビゲーションは維持 |
| L3: コンポーネント | 独立した機能ブロック(グラフ、ファイルアップロード等) | コンポーネント単位 | 該当ブロックのみ「表示できません」。画面の他の部分は正常動作 |
エラー報告:
- 全層の Error Boundary で捕捉したエラーをフロントエンドエラー追跡サービスに自動送信(→ QA-003)
- L1(アプリケーション層)でのエラーには
TraceIdを画面に表示し、ユーザーがサポートに問い合わせる際の手がかりとする
各層のフォールバック UI に TraceId 表示を統合した詳細:
L1: アプリケーション層
各ポータル(/employee/, /admin/, /agency/, /system/)のルートレイアウトに配置。Sentry の ErrorBoundary コンポーネントを使用し、eventId をフォールバック UI に表示する:
typescript
import * as Sentry from "@sentry/react";
function AppErrorFallback({
error,
eventId,
resetError,
}: {
error: Error;
eventId: string;
resetError: () => void;
}) {
const traceId =
error instanceof ApiError ? error.traceId : null;
return (
<main role="alert" aria-live="assertive">
<h1>予期しないエラーが発生しました</h1>
<p>
サポートにお問い合わせの際は以下のIDをお伝えください:
</p>
<dl>
{traceId && (
<>
<dt>TraceId</dt>
<dd><code>{traceId}</code></dd>
</>
)}
<dt>EventId</dt>
<dd><code>{eventId}</code></dd>
</dl>
<button onClick={resetError}>
ページをリロード
</button>
</main>
);
}
// ポータルのルートレイアウトで使用
function EmployeePortalLayout() {
return (
<Sentry.ErrorBoundary
fallback={AppErrorFallback}
showDialog={false}
>
<Outlet />
</Sentry.ErrorBoundary>
);
}L2: ルート層
TanStack Router の errorComponent として配置。該当画面のみエラー表示し、サイドバー・ナビゲーションは維持:
typescript
export const declarationRoute = createRoute({
getParentRoute: () => employeeLayout,
path: "$taxYear/declaration",
component: DeclarationPage,
errorComponent: ({ error }) => {
const traceId =
error instanceof ApiError ? error.traceId : null;
return (
<section role="alert" aria-live="polite">
<h2>この画面の表示中にエラーが発生しました</h2>
{traceId && (
<p>
TraceId: <code>{traceId}</code>
</p>
)}
<nav>
<button onClick={() => window.location.reload()}>
再読み込み
</button>
<Link to="..">前の画面に戻る</Link>
</nav>
</section>
);
},
});L3: コンポーネント層
独立した機能ブロック(グラフ、ファイルアップロード等)に配置。周囲のコンテンツに影響を与えず、再試行ボタンを提供:
typescript
function WidgetErrorFallback({
resetErrorBoundary,
}: {
resetErrorBoundary: () => void;
}) {
return (
<div role="alert" aria-live="polite">
<p>表示できません</p>
<button onClick={resetErrorBoundary}>再試行</button>
</div>
);
}TanStack Query エラーハンドリング
グローバル onError コールバック
すべてのクエリ/ミューテーションのエラーを Sentry に送信し、traceId をタグとして付与する:
typescript
import * as Sentry from "@sentry/react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof ApiError) {
// 認証/認可エラーはリトライしない
if (error.status === 401 || error.status === 403) {
return false;
}
// システムエラーは最大3回リトライ
if (error.status >= 500) {
return failureCount < 3;
}
}
return false;
},
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 8000) +
Math.random() * 1000, // Exponential Backoff + Jitter
},
mutations: {
onError: (error) => {
if (error instanceof ApiError && error.traceId) {
Sentry.withScope((scope) => {
scope.setTag("traceId", error.traceId);
scope.setTag("errorCode", error.errorCode ?? "unknown");
scope.setTag("httpStatus", String(error.status));
Sentry.captureException(error);
});
}
},
},
},
});throwOnError 戦略
| 条件 | throwOnError | 理由 |
|---|---|---|
| 5xx エラー(リトライ後も失敗) | true | Error Boundary で包括的にハンドリング |
| 401 エラー | false | グローバル Interceptor でリフレッシュ/リダイレクト処理 |
| 400 / 422 エラー(フォーム送信時) | false | フィールドレベルエラーとしてインライン表示 |
| 409 エラー(競合) | false | 競合解決 UI をインライン表示 |
Sentry 統合
初期化設定
typescript
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
environment: import.meta.env.MODE,
release: import.meta.env.VITE_RELEASE_VERSION,
// トレース伝播対象: TASHIKA API のみ
tracePropagationTargets: [
/^https:\/\/api\.tashika\.example/,
],
// パフォーマンストレースのサンプリングレート
tracesSampleRate: import.meta.env.PROD ? 0.1 : 1.0,
// エラー発生セッションの Session Replay キャプチャ率
replaysOnErrorSampleRate: 1.0, // エラー時は100%キャプチャ
replaysSessionSampleRate: 0.0, // 通常セッションはキャプチャしない
integrations: [
Sentry.replayIntegration({
// マイナンバー関連フィールドのマスキング
maskAllText: false,
maskAllInputs: false,
mask: [
"[data-sentry-mask]", // 明示的にマスク指定された要素
"[name*='mynumber']", // マイナンバー入力フィールド
"[name*='my_number']", // マイナンバー入力フィールド(別名)
"[data-sensitive='true']", // 機密データフィールド
],
}),
Sentry.browserTracingIntegration(),
],
// エラー送信前のデータスクラビング
beforeSend(event) {
// マイナンバーパターン(12桁数字)をスクラブ
const mynumberPattern = /\b\d{12}\b/g;
if (event.message) {
event.message = event.message.replace(
mynumberPattern,
"[REDACTED]",
);
}
return event;
},
});ユーザーコンテキスト設定
ログイン後にユーザーコンテキストを設定する。機密情報は含めない:
typescript
function setUserContext(user: AuthenticatedUser) {
Sentry.setUser({
id: user.userId,
});
Sentry.setTag("tenantId", user.tenantId);
Sentry.setTag("portal", user.portal);
Sentry.setTag("role", user.role);
// 注意: マイナンバー、メールアドレス、氏名は設定しない
}チャンク読み込みエラー対策
SPA のデプロイ後に、ユーザーのブラウザが古いチャンクを参照して ChunkLoadError が発生するケースに対応する:
| 対策 | 説明 |
|---|---|
| 自動リロード | ChunkLoadError を検出した場合、1回だけ自動リロードを試行する(無限リロードループを防止するため、SessionStorage でリロード済みフラグを管理) |
| 旧チャンク保持 | デプロイ時に旧バージョンの静的アセットを一定期間(最低1時間)CDN 上に保持する |
| ユーザー通知 | 自動リロードでも解消しない場合、「新しいバージョンが利用可能です。ページを更新してください」のバナーを表示する |
→ 満たす要件: PE-004 ユーザビリティ、QA-003 可観測性