diff --git a/apps/desktop/src/renderer/components/RendererRouter/RendererRouter.test.ts b/apps/desktop/src/renderer/components/RendererRouter/RendererRouter.test.ts new file mode 100644 index 00000000000..3d0fdab53c7 --- /dev/null +++ b/apps/desktop/src/renderer/components/RendererRouter/RendererRouter.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; + +test("root failures retain real translation context and report the original error", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, `${import.meta.dir}/fixtures/route-errors.tsx`], + env: { ...process.env, NODE_ENV: "test" }, + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(result.stdout.toString().trim()).toBe("passed"); +}); diff --git a/apps/desktop/src/renderer/components/RendererRouter/RendererRouter.tsx b/apps/desktop/src/renderer/components/RendererRouter/RendererRouter.tsx new file mode 100644 index 00000000000..ff61d76f95c --- /dev/null +++ b/apps/desktop/src/renderer/components/RendererRouter/RendererRouter.tsx @@ -0,0 +1,13 @@ +import { I18nProvider } from "@lingui/react"; +import { i18n, initI18n } from "@superset/i18n"; +import { type AnyRouter, RouterProvider } from "@tanstack/react-router"; + +if (!i18n.locale) initI18n(); + +export function RendererRouter({ router }: { router: AnyRouter }) { + return ( + + + + ); +} diff --git a/apps/desktop/src/renderer/components/RendererRouter/fixtures/route-errors.tsx b/apps/desktop/src/renderer/components/RendererRouter/fixtures/route-errors.tsx new file mode 100644 index 00000000000..1f09953c270 --- /dev/null +++ b/apps/desktop/src/renderer/components/RendererRouter/fixtures/route-errors.tsx @@ -0,0 +1,156 @@ +import { mock } from "bun:test"; +import { transformAsync } from "@babel/core"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import linguiMacro from "@lingui/babel-plugin-lingui-macro"; +import { plugin } from "bun"; +import type { ReactNode } from "react"; + +plugin({ + name: "real-lingui-react-macros", + setup(build) { + build.onLoad({ filter: /\/(error|not-found)\.tsx$/ }, async ({ path }) => { + if (path.includes("/node_modules/")) return; + const code = await Bun.file(path).text(); + if (!/@lingui\/(react|core)\/macro/.test(code)) return; + const result = await transformAsync(code, { + filename: path, + babelrc: false, + configFile: false, + parserOpts: { plugins: ["typescript", "jsx"] }, + plugins: [linguiMacro], + }); + if (!result?.code) throw new Error(`Could not compile ${path}`); + return { contents: result.code, loader: "tsx" }; + }); + }, +}); + +GlobalRegistrator.register({ url: "http://localhost" }); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const captured: unknown[] = []; +mock.module("@sentry/electron/renderer", () => ({ + captureException: (error: unknown) => captured.push(error), +})); +const originalError = new Error("Original route failure"); +let failLayout = false; +mock.module("../../../routes/-layout", () => ({ + RootLayout: ({ children }: { children: ReactNode }) => { + if (failLayout) throw originalError; + return children; + }, +})); + +const { act, cleanup, fireEvent, render, waitFor } = await import( + "@testing-library/react" +); +const { createMemoryHistory, createRoute, createRouter } = await import( + "@tanstack/react-router" +); +const { QueryClient } = await import("@tanstack/react-query"); +const { i18n, initI18nAsync } = await import("@superset/i18n"); +const { RendererRouter } = await import("../RendererRouter"); +const { Route } = await import("../../../routes/__root"); + +const home = createRoute({ + getParentRoute: () => Route, + path: "/", + component: () =>
, +}); +const broken = createRoute({ + getParentRoute: () => Route, + path: "/broken", + loader: () => { + throw originalError; + }, +}); +const routeTree = Route.addChildren([home, broken]); +const consoleErrors: unknown[][] = []; +console.error = (...args: unknown[]) => consoleErrors.push(args); +console.warn = () => {}; + +function check(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +try { + for (const failure of ["layout", "loader", "not-found"] as const) { + captured.length = 0; + consoleErrors.length = 0; + failLayout = failure === "layout"; + await initI18nAsync("fr"); + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: [ + failure === "loader" + ? "/broken" + : failure === "not-found" + ? "/missing" + : "/", + ], + }), + context: { queryClient: new QueryClient() }, + }); + await act(async () => { + render(); + await router.load(); + }); + const heading = + failure === "not-found" ? "Page introuvable" : "Une erreur est survenue"; + await waitFor(() => + check( + document.body.textContent?.includes(heading), + `${failure}: missing translated fallback`, + ), + ); + check(i18n.locale === "fr", `${failure}: fallback reset the active locale`); + if (failure !== "not-found") { + await waitFor(() => + check( + captured.includes(originalError), + `${failure}: original error not reported`, + ), + ); + await act(async () => { + fireEvent.click( + document.querySelector( + "button[aria-controls='error-details']", + ) as Element, + ); + }); + check( + document + .querySelector("pre") + ?.textContent?.includes(originalError.stack ?? originalError.message), + `${failure}: original error details missing`, + ); + } else { + check(captured.length === 0, "404 was reported as a crash"); + } + check( + !consoleErrors.some((args) => + args.some((arg) => /useLingui|I18nProvider/.test(String(arg))), + ), + `${failure}: translation context failed`, + ); + if (failure !== "layout") { + await act(async () => { + fireEvent.click(document.querySelector("a") as Element); + }); + await waitFor(() => + check( + document.querySelector('[data-testid="recovered-home"]'), + "Go home did not recover", + ), + ); + } + cleanup(); + } + console.log("passed"); +} finally { + cleanup(); + await GlobalRegistrator.unregister(); +} diff --git a/apps/desktop/src/renderer/components/RendererRouter/index.ts b/apps/desktop/src/renderer/components/RendererRouter/index.ts new file mode 100644 index 00000000000..9581a37736f --- /dev/null +++ b/apps/desktop/src/renderer/components/RendererRouter/index.ts @@ -0,0 +1 @@ +export { RendererRouter } from "./RendererRouter"; diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index db09122b855..7fafa4a5124 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -2,9 +2,10 @@ import { initSentry } from "./lib/sentry"; initSentry(); -import { createRouter, RouterProvider } from "@tanstack/react-router"; +import { createRouter } from "@tanstack/react-router"; import ReactDom from "react-dom/client"; import { BootErrorBoundary } from "./components/BootErrorBoundary"; +import { RendererRouter } from "./components/RendererRouter"; import { cleanupBootErrorHandling, initBootErrorHandling, @@ -85,7 +86,7 @@ if (!rootElement) { reportBootError("Render failed", error)} > - + , ); markBootMounted();