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:
2026-08-01 09:48:49 +02:00
parent 961bf4623f
commit c19fb6bf8c
5 changed files with 260 additions and 12 deletions

View 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();
}
});
});

View 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;
}
}

View File

@ -7,6 +7,7 @@ import { App } from "./App";
import { ViewWindow, parseViewWindowParams } from "./ViewWindow"; import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
import { WebApp } from "@/features/web"; import { WebApp } from "@/features/web";
import { DIProvider, resolveTransport } from "./di"; import { DIProvider, resolveTransport } from "./di";
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
import "@/shared/styles/theme.css"; import "@/shared/styles/theme.css";
const root = document.getElementById("root"); 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. // the full `App` (or a detached view window). Detached windows are desktop-only.
const isWeb = resolveTransport() === "http"; const isWeb = resolveTransport() === "http";
installGlobalErrorLogging();
ReactDOM.createRoot(root).render( ReactDOM.createRoot(root).render(
<React.StrictMode> <React.StrictMode>
<DIProvider> <RootErrorBoundary>
{isWeb ? ( <DIProvider>
<WebApp /> {isWeb ? (
) : viewParams ? ( <WebApp />
<ViewWindow panel={viewParams.panel} /> ) : viewParams ? (
) : ( <ViewWindow panel={viewParams.panel} />
<App /> ) : (
)} <App />
</DIProvider> )}
</DIProvider>
</RootErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@ -305,6 +305,28 @@ describe("loadPlugins", () => {
); );
}); });
it("collects a failure when plugin activation does not settle", async () => {
const bundle = dataUrl(`
export function activate() {
return new Promise(() => {});
}
`);
const { registry, failures } = await loadPlugins(
[entry({ id: "dev.acme.hung", displayName: "Hung", bundleUrl: bundle })],
gateways,
{ timeoutMs: 10 },
);
expect(registry.list()).toEqual([]);
expect(failures).toEqual([
{
pluginId: "dev.acme.hung",
reason: "activating plugin timed out after 10 ms",
},
]);
});
it("only loads what the catalog contains — a disabled/absent plugin is simply never in it", async () => { it("only loads what the catalog contains — a disabled/absent plugin is simply never in it", async () => {
// The backend contract (carnet §1.3) filters the catalog to // The backend contract (carnet §1.3) filters the catalog to
// `enabled && !pendingUninstall` before the loader ever sees it; the // `enabled && !pendingUninstall` before the loader ever sees it; the

View File

@ -72,6 +72,32 @@ export interface PluginLoadResult {
failures: PluginLoadFailure[]; failures: PluginLoadFailure[];
} }
export interface PluginLoadOptions {
timeoutMs?: number;
}
const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000;
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs} ms`));
}, timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule { function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
return ( return (
typeof mod === "object" && typeof mod === "object" &&
@ -170,6 +196,7 @@ async function disposeAll(disposables: Disposable[], activation?: void | PluginA
async function loadOne( async function loadOne(
entry: PluginRuntimePlugin, entry: PluginRuntimePlugin,
gateways: PluginGatewaySet, gateways: PluginGatewaySet,
options: Required<PluginLoadOptions>,
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> { ): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
const entryObject = objectOrEmpty(entry); const entryObject = objectOrEmpty(entry);
const pluginId = safePluginId(entry); const pluginId = safePluginId(entry);
@ -186,7 +213,13 @@ async function loadOne(
throw new Error("missing plugin bundle URL"); throw new Error("missing plugin bundle URL");
} }
const mod = resolveIdeaPluginModule(await import(/* @vite-ignore */ bundleUrl)); const mod = resolveIdeaPluginModule(
await withTimeout(
import(/* @vite-ignore */ bundleUrl),
options.timeoutMs,
"importing plugin bundle",
),
);
if (!mod) { if (!mod) {
return { return {
failure: { failure: {
@ -215,7 +248,11 @@ async function loadOne(
...gateways, ...gateways,
}; };
activation = await mod.activate(ctx); activation = await withTimeout(
Promise.resolve(mod.activate(ctx)),
options.timeoutMs,
"activating plugin",
);
const plugin: LoadedPlugin = { const plugin: LoadedPlugin = {
pluginId, pluginId,
@ -251,12 +288,18 @@ async function loadOne(
export async function loadPlugins( export async function loadPlugins(
catalogPlugins: PluginRuntimePlugin[], catalogPlugins: PluginRuntimePlugin[],
gateways: PluginGatewaySet, gateways: PluginGatewaySet,
options: PluginLoadOptions = {},
): Promise<PluginLoadResult> { ): Promise<PluginLoadResult> {
const registry = new PluginRuntimeRegistry(); const registry = new PluginRuntimeRegistry();
const failures: PluginLoadFailure[] = []; const failures: PluginLoadFailure[] = [];
const resolvedOptions: Required<PluginLoadOptions> = {
timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS,
};
const entries = Array.isArray(catalogPlugins) ? catalogPlugins : []; const entries = Array.isArray(catalogPlugins) ? catalogPlugins : [];
const results = await Promise.all(entries.map((entry) => loadOne(entry, gateways))); const results = await Promise.all(
entries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
);
for (const result of results) { for (const result of results) {
if ("failure" in result) failures.push(result.failure); if ("failure" in result) failures.push(result.failure);
else registry.add(result.plugin); else registry.add(result.plugin);