fix(frontend): écran noir plutôt que muet sur crash de plugin ou d'activation figée
Complète le diagnostic backend (#120) côté frontend : un crash React non capturé (ex. plugin cassant le rendu) laissait un écran noir sans trace, et une activation de plugin qui ne se résout jamais (promesse infinie) bloquait le chargement sans échouer. Ajoute RootErrorBoundary + logging d'erreurs globales autour de l'arbre React, et un timeout sur l'import/l'activation de chaque plugin dans loadPlugins pour transformer un hang silencieux en échec explicite et diagnosticable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
76
frontend/src/app/RootErrorBoundary.test.tsx
Normal file
76
frontend/src/app/RootErrorBoundary.test.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
|
||||
|
||||
function ThrowingView(): JSX.Element {
|
||||
throw new Error("boom from render");
|
||||
}
|
||||
|
||||
describe("RootErrorBoundary", () => {
|
||||
it("renders a visible fallback when the app render tree throws", () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
render(
|
||||
<RootErrorBoundary>
|
||||
<ThrowingView />
|
||||
</RootErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByText("IdeA a rencontré une erreur d'affichage.")).toBeTruthy();
|
||||
expect(screen.getByText(/boom from render/)).toBeTruthy();
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a reload action from the fallback", () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const reload = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
try {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, reload },
|
||||
});
|
||||
|
||||
render(
|
||||
<RootErrorBoundary>
|
||||
<ThrowingView />
|
||||
</RootErrorBoundary>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Recharger" }));
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("installGlobalErrorLogging", () => {
|
||||
it("installs global error logging only once", () => {
|
||||
const addEventListener = vi.spyOn(window, "addEventListener");
|
||||
const previousFlag = window.__IDEA_GLOBAL_ERROR_LOGGING__;
|
||||
try {
|
||||
window.__IDEA_GLOBAL_ERROR_LOGGING__ = undefined;
|
||||
installGlobalErrorLogging();
|
||||
installGlobalErrorLogging();
|
||||
|
||||
expect(
|
||||
addEventListener.mock.calls.filter(([event]) => event === "error"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
addEventListener.mock.calls.filter(([event]) => event === "unhandledrejection"),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
window.__IDEA_GLOBAL_ERROR_LOGGING__ = previousFlag;
|
||||
addEventListener.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
102
frontend/src/app/RootErrorBoundary.tsx
Normal file
102
frontend/src/app/RootErrorBoundary.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
const UNKNOWN_ERROR = "Erreur frontend inconnue";
|
||||
|
||||
export function installGlobalErrorLogging(): void {
|
||||
const globalWindow = globalThis.window;
|
||||
if (!globalWindow || globalWindow.__IDEA_GLOBAL_ERROR_LOGGING__) return;
|
||||
globalWindow.__IDEA_GLOBAL_ERROR_LOGGING__ = true;
|
||||
|
||||
globalWindow.addEventListener("error", (event) => {
|
||||
console.error("[idea-ui] uncaught error", {
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
error: event.error,
|
||||
});
|
||||
});
|
||||
|
||||
globalWindow.addEventListener("unhandledrejection", (event) => {
|
||||
console.error("[idea-ui] unhandled promise rejection", event.reason);
|
||||
});
|
||||
}
|
||||
|
||||
interface RootErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface RootErrorBoundaryState {
|
||||
error: Error | null;
|
||||
componentStack: string | null;
|
||||
}
|
||||
|
||||
export class RootErrorBoundary extends Component<
|
||||
RootErrorBoundaryProps,
|
||||
RootErrorBoundaryState
|
||||
> {
|
||||
state: RootErrorBoundaryState = {
|
||||
error: null,
|
||||
componentStack: null,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): RootErrorBoundaryState {
|
||||
return { error, componentStack: null };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error("[idea-ui] root render failed", error, errorInfo);
|
||||
this.setState({ componentStack: errorInfo.componentStack ?? null });
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const { error, componentStack } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
const message = error.message || UNKNOWN_ERROR;
|
||||
const details = [error.stack, componentStack].filter(Boolean).join("\n\n");
|
||||
|
||||
return (
|
||||
<main className="flex h-full items-center justify-center bg-canvas p-6 text-content">
|
||||
<section
|
||||
role="alert"
|
||||
aria-labelledby="root-error-title"
|
||||
className="flex w-full max-w-2xl flex-col gap-4 rounded-lg border border-danger/50 bg-surface p-5 shadow-xl"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs font-medium uppercase text-danger">
|
||||
Interface interrompue
|
||||
</p>
|
||||
<h1 id="root-error-title" className="text-lg font-semibold">
|
||||
IdeA a rencontré une erreur d'affichage.
|
||||
</h1>
|
||||
<p className="text-sm text-muted">
|
||||
L'application reste ouverte. Rechargez la fenêtre après avoir copié
|
||||
le diagnostic si le problème doit être investigué.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-canvas p-3 font-mono text-xs leading-relaxed text-muted">
|
||||
{details || message}
|
||||
</pre>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-on-primary hover:bg-primary-hover"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Recharger
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__IDEA_GLOBAL_ERROR_LOGGING__?: boolean;
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { App } from "./App";
|
||||
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
||||
import { WebApp } from "@/features/web";
|
||||
import { DIProvider, resolveTransport } from "./di";
|
||||
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
|
||||
import "@/shared/styles/theme.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
@ -32,16 +33,20 @@ const viewParams = parseViewWindowParams(window.location.search);
|
||||
// the full `App` (or a detached view window). Detached windows are desktop-only.
|
||||
const isWeb = resolveTransport() === "http";
|
||||
|
||||
installGlobalErrorLogging();
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<DIProvider>
|
||||
{isWeb ? (
|
||||
<WebApp />
|
||||
) : viewParams ? (
|
||||
<ViewWindow panel={viewParams.panel} />
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
</DIProvider>
|
||||
<RootErrorBoundary>
|
||||
<DIProvider>
|
||||
{isWeb ? (
|
||||
<WebApp />
|
||||
) : viewParams ? (
|
||||
<ViewWindow panel={viewParams.panel} />
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
</DIProvider>
|
||||
</RootErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user