Add error boundary, error page, and startup timeout
Prevent infinite spinner when DB connection fails at startup by adding a 10s timeout on connectActiveProfile(). Add ErrorBoundary to catch React crashes and ErrorPage with refresh, update check, and contact links. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f126d08da3
commit
849945f339
9 changed files with 239 additions and 12 deletions
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Error boundary catches React crashes and displays an error page instead of a white screen
|
||||
- Startup timeout (10s) on database connection — shows error page instead of infinite spinner
|
||||
- Error page with "Refresh", "Check for updates", and contact/issue links
|
||||
|
||||
## [0.4.4]
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -9,7 +9,7 @@
|
|||
**Stockage :** SQLite local (tauri-plugin-sql)
|
||||
**Langues supportées :** Français (FR) et Anglais (EN)
|
||||
**Plateformes :** Windows, Linux
|
||||
**Version actuelle :** 0.3.11
|
||||
**Version actuelle :** 0.4.4
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
|
||||
```
|
||||
src/
|
||||
├── components/ # 49 composants React organisés par domaine
|
||||
├── components/ # 53 composants React organisés par domaine
|
||||
│ ├── adjustments/ # Ajustements
|
||||
│ ├── budget/ # Budget
|
||||
│ ├── categories/ # Catégories hiérarchiques
|
||||
|
|
@ -67,7 +67,7 @@ src-tauri/
|
|||
│ │ ├── schema.sql # Schéma initial (v1)
|
||||
│ │ ├── seed_categories.sql # Seed catégories (v2)
|
||||
│ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils)
|
||||
│ ├── lib.rs # Point d'entrée, 6 migrations inline, plugins
|
||||
│ ├── lib.rs # Point d'entrée, 7 migrations inline, plugins
|
||||
│ └── main.rs
|
||||
└── Cargo.toml
|
||||
```
|
||||
|
|
@ -114,8 +114,8 @@ src-tauri/
|
|||
|
||||
## Base de données
|
||||
|
||||
- **13 tables** SQLite, **9 index** (voir `docs/architecture.md` pour le détail)
|
||||
- **6 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`)
|
||||
- **13 tables** SQLite, **15 index** (voir `docs/architecture.md` pour le détail)
|
||||
- **7 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`)
|
||||
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
|
||||
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
|
|||
## CI/CD
|
||||
|
||||
- GitHub Actions (`release.yml`) déclenché par tags `v*`
|
||||
- Build Windows (NSIS `.exe`) + Linux (`.deb`, `.AppImage`)
|
||||
- Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`)
|
||||
- Signature des binaires + JSON d'updater pour mises à jour automatiques
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ simpl-resultat/
|
|||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||
│ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
|
||||
│ │ ├── settings/ # 2 composants
|
||||
│ │ ├── shared/ # 4 composants réutilisables
|
||||
│ │ ├── shared/ # 6 composants réutilisables
|
||||
│ │ └── transactions/ # 5 composants
|
||||
│ ├── contexts/ # ProfileContext (état global profil)
|
||||
│ ├── hooks/ # 12 hooks custom (useReducer)
|
||||
|
|
@ -176,6 +176,12 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
|
||||
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
|
||||
|
||||
### Gestion d'erreurs
|
||||
|
||||
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
|
||||
- **`ErrorPage`** : page d'erreur réutilisable avec détails techniques (collapsible), bouton "Actualiser", vérification de mises à jour, et liens de contact/issues
|
||||
- **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue
|
||||
|
||||
| Route | Page | Description |
|
||||
|-------|------|-------------|
|
||||
| `/` | `DashboardPage` | Tableau de bord avec graphiques |
|
||||
|
|
|
|||
36
src/App.tsx
36
src/App.tsx
|
|
@ -1,5 +1,6 @@
|
|||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useProfile } from "./contexts/ProfileContext";
|
||||
import AppShell from "./components/layout/AppShell";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
|
|
@ -12,19 +13,42 @@ import ReportsPage from "./pages/ReportsPage";
|
|||
import SettingsPage from "./pages/SettingsPage";
|
||||
import DocsPage from "./pages/DocsPage";
|
||||
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||
import ErrorPage from "./components/shared/ErrorPage";
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 10_000;
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
||||
const [dbReady, setDbReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProfile && !isLoading) {
|
||||
setDbReady(false);
|
||||
setStartupError(null);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setStartupError(t("error.startupTimeout"));
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
|
||||
connectActiveProfile()
|
||||
.then(() => setDbReady(true))
|
||||
.catch((err) => console.error("Failed to connect profile:", err));
|
||||
.then(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setDbReady(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
console.error("Failed to connect profile:", err);
|
||||
setStartupError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
}, [activeProfile, isLoading, connectActiveProfile]);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [activeProfile, isLoading, connectActiveProfile, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -34,6 +58,10 @@ export default function App() {
|
|||
);
|
||||
}
|
||||
|
||||
if (startupError) {
|
||||
return <ErrorPage error={startupError} />;
|
||||
}
|
||||
|
||||
if (!activeProfile) {
|
||||
return <ProfileSelectionPage />;
|
||||
}
|
||||
|
|
|
|||
34
src/components/shared/ErrorBoundary.tsx
Normal file
34
src/components/shared/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Component, type ReactNode } from "react";
|
||||
import ErrorPage from "./ErrorPage";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error: error.message };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <ErrorPage error={this.state.error ?? undefined} />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
123
src/components/shared/ErrorPage.tsx
Normal file
123
src/components/shared/ErrorPage.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
|
||||
interface ErrorPageProps {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function ErrorPage({ error }: ErrorPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "error">("idle");
|
||||
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setUpdateStatus("checking");
|
||||
setUpdateError(null);
|
||||
try {
|
||||
const update = await check();
|
||||
if (update) {
|
||||
setUpdateStatus("available");
|
||||
setUpdateVersion(update.version);
|
||||
} else {
|
||||
setUpdateStatus("upToDate");
|
||||
}
|
||||
} catch (e) {
|
||||
setUpdateStatus("error");
|
||||
setUpdateError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-[var(--background)] p-4">
|
||||
<div className="max-w-md w-full space-y-6 text-center">
|
||||
<AlertTriangle className="mx-auto h-16 w-16 text-[var(--destructive)]" />
|
||||
|
||||
<h1 className="text-2xl font-bold text-[var(--foreground)]">
|
||||
{t("error.title")}
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="inline-flex items-center gap-1 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
{showDetails ? t("error.hideDetails") : t("error.showDetails")}
|
||||
{showDetails ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
{showDetails && (
|
||||
<pre className="mt-2 p-3 bg-[var(--muted)] rounded-md text-xs text-left text-[var(--muted-foreground)] overflow-auto max-h-40">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t("error.refresh")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={updateStatus === "checking"}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{updateStatus === "checking" ? t("common.loading") : t("error.checkUpdate")}
|
||||
</button>
|
||||
|
||||
{updateStatus === "available" && updateVersion && (
|
||||
<p className="text-sm text-[var(--primary)]">
|
||||
{t("error.updateAvailable", { version: updateVersion })}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === "upToDate" && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("error.upToDate")}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === "error" && updateError && (
|
||||
<p className="text-sm text-[var(--destructive)]">{updateError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[var(--border)]">
|
||||
<p className="text-sm font-medium text-[var(--foreground)] mb-3">
|
||||
{t("error.contactUs")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<a
|
||||
href="mailto:lacompagniemaximus@protonmail.com"
|
||||
className="inline-flex items-center justify-center gap-2 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
{t("error.contactEmail")} lacompagniemaximus@protonmail.com
|
||||
</a>
|
||||
<a
|
||||
href="https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<Bug className="h-4 w-4" />
|
||||
{t("error.reportIssue")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -778,6 +778,20 @@
|
|||
"manageProfiles": "Manage Profiles",
|
||||
"default": "Default"
|
||||
},
|
||||
"error": {
|
||||
"title": "An error occurred",
|
||||
"startupTimeout": "Database connection timed out",
|
||||
"unexpectedError": "An unexpected error occurred",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"refresh": "Refresh",
|
||||
"checkUpdate": "Check for updates",
|
||||
"updateAvailable": "Update available: v{{version}}",
|
||||
"upToDate": "The application is up to date",
|
||||
"contactUs": "Contact us",
|
||||
"contactEmail": "Send an email to",
|
||||
"reportIssue": "Report an issue"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
|
|
|
|||
|
|
@ -778,6 +778,20 @@
|
|||
"manageProfiles": "Gérer les profils",
|
||||
"default": "Par défaut"
|
||||
},
|
||||
"error": {
|
||||
"title": "Une erreur est survenue",
|
||||
"startupTimeout": "La connexion à la base de données a expiré",
|
||||
"unexpectedError": "Une erreur inattendue s'est produite",
|
||||
"showDetails": "Afficher les détails",
|
||||
"hideDetails": "Masquer les détails",
|
||||
"refresh": "Actualiser",
|
||||
"checkUpdate": "Vérifier les mises à jour",
|
||||
"updateAvailable": "Mise à jour disponible : v{{version}}",
|
||||
"upToDate": "L'application est à jour",
|
||||
"contactUs": "Nous contacter",
|
||||
"contactEmail": "Envoyez un email à",
|
||||
"reportIssue": "Signaler un problème"
|
||||
},
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ import React from "react";
|
|||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ProfileProvider } from "./contexts/ProfileContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import "./i18n/config";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ProfileProvider>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</ProfileProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue