From c19fb6bf8ccb91a66bd0784297583162f9058167 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 1 Aug 2026 09:48:49 +0200 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=C3=A9cran=20noir=20plut=C3=B4?= =?UTF-8?q?t=20que=20muet=20sur=20crash=20de=20plugin=20ou=20d'activation?= =?UTF-8?q?=20fig=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/app/RootErrorBoundary.test.tsx | 76 +++++++++++++++ frontend/src/app/RootErrorBoundary.tsx | 102 ++++++++++++++++++++ frontend/src/app/main.tsx | 23 +++-- frontend/src/plugins/runtime/loader.test.ts | 22 +++++ frontend/src/plugins/runtime/loader.ts | 49 +++++++++- 5 files changed, 260 insertions(+), 12 deletions(-) create mode 100644 frontend/src/app/RootErrorBoundary.test.tsx create mode 100644 frontend/src/app/RootErrorBoundary.tsx diff --git a/frontend/src/app/RootErrorBoundary.test.tsx b/frontend/src/app/RootErrorBoundary.test.tsx new file mode 100644 index 0000000..55a6de4 --- /dev/null +++ b/frontend/src/app/RootErrorBoundary.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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(); + } + }); +}); diff --git a/frontend/src/app/RootErrorBoundary.tsx b/frontend/src/app/RootErrorBoundary.tsx new file mode 100644 index 0000000..802405b --- /dev/null +++ b/frontend/src/app/RootErrorBoundary.tsx @@ -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 ( +
+
+
+

+ Interface interrompue +

+

+ IdeA a rencontré une erreur d'affichage. +

+

+ L'application reste ouverte. Rechargez la fenêtre après avoir copié + le diagnostic si le problème doit être investigué. +

+
+ +
+            {details || message}
+          
+ +
+ +
+
+
+ ); + } +} + +declare global { + interface Window { + __IDEA_GLOBAL_ERROR_LOGGING__?: boolean; + } +} diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index d2985d8..ef62dd4 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -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( - - {isWeb ? ( - - ) : viewParams ? ( - - ) : ( - - )} - + + + {isWeb ? ( + + ) : viewParams ? ( + + ) : ( + + )} + + , ); diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 79329d6..5485782 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -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 () => { // The backend contract (carnet §1.3) filters the catalog to // `enabled && !pendingUninstall` before the loader ever sees it; the diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 77d4803..788aa36 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -72,6 +72,32 @@ export interface PluginLoadResult { failures: PluginLoadFailure[]; } +export interface PluginLoadOptions { + timeoutMs?: number; +} + +const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000; + +async function withTimeout( + promise: Promise, + timeoutMs: number, + label: string, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, 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 { return ( typeof mod === "object" && @@ -170,6 +196,7 @@ async function disposeAll(disposables: Disposable[], activation?: void | PluginA async function loadOne( entry: PluginRuntimePlugin, gateways: PluginGatewaySet, + options: Required, ): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> { const entryObject = objectOrEmpty(entry); const pluginId = safePluginId(entry); @@ -186,7 +213,13 @@ async function loadOne( 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) { return { failure: { @@ -215,7 +248,11 @@ async function loadOne( ...gateways, }; - activation = await mod.activate(ctx); + activation = await withTimeout( + Promise.resolve(mod.activate(ctx)), + options.timeoutMs, + "activating plugin", + ); const plugin: LoadedPlugin = { pluginId, @@ -251,12 +288,18 @@ async function loadOne( export async function loadPlugins( catalogPlugins: PluginRuntimePlugin[], gateways: PluginGatewaySet, + options: PluginLoadOptions = {}, ): Promise { const registry = new PluginRuntimeRegistry(); const failures: PluginLoadFailure[] = []; + const resolvedOptions: Required = { + timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS, + }; 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) { if ("failure" in result) failures.push(result.failure); else registry.add(result.plugin);