diff --git a/package-lock.json b/package-lock.json index 469505abf..1c4940098 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@microbit/microbit-fs": "^0.10.0", "@microbit/micropython-microbit-stubs": "^1.0.0", "@microbit/ui": "^0.4.0", - "@microbit/ui-patterns": "^0.5.0", + "@microbit/ui-patterns": "^0.6.1", "@sanity/block-content-to-react": "^3.0.0", "@sanity/image-url": "^1.0.1", "@sentry/browser": "^10.71.0", @@ -3396,9 +3396,9 @@ } }, "node_modules/@microbit/ui-patterns": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@microbit/ui-patterns/-/ui-patterns-0.5.1.tgz", - "integrity": "sha512-Njw+XJlx04W/xfP+QwR/13SXRYFieVhEeXCVQwxbBZraXxKYLNmnyP6mmf9KLRT88lLEjquD4laBn6bj3HXMqg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@microbit/ui-patterns/-/ui-patterns-0.6.1.tgz", + "integrity": "sha512-Imnmc9Jpg2M80/3aY3HKaK3sUHjLWLQtoqzuj/IedjT8Ozsige/mCZE5g9efwYqcT4xIxpI80gvFBSWvKvigqw==", "license": "MIT", "dependencies": { "@formatjs/intl-localematcher": "^0.8.0" diff --git a/package.json b/package.json index 43eb41652..f89d5f57c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@microbit/microbit-fs": "^0.10.0", "@microbit/micropython-microbit-stubs": "^1.0.0", "@microbit/ui": "^0.4.0", - "@microbit/ui-patterns": "^0.5.0", + "@microbit/ui-patterns": "^0.6.1", "@sanity/block-content-to-react": "^3.0.0", "@sanity/image-url": "^1.0.1", "@sentry/browser": "^10.71.0", diff --git a/src/RootLayout.test.tsx b/src/RootLayout.test.tsx new file mode 100644 index 000000000..c1543832b --- /dev/null +++ b/src/RootLayout.test.tsx @@ -0,0 +1,55 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { SharedUIProvider } from "@microbit/ui"; +import { render, screen } from "@testing-library/react"; +import { vi } from "vitest"; +import { IntlProvider } from "react-intl"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { LoggingProvider } from "./logging/logging-hooks"; +import { MockLogging } from "./logging/mock"; +import RootLayout from "./RootLayout"; + +const Thrower = () => { + throw new Error("boom"); +}; + +it("shows the error page with the report reference when a route throws", () => { + // React reports the caught error to console.error and jsdom raises it as a + // window error event; silence both. + vi.spyOn(console, "error").mockImplementation(() => {}); + const swallow = (e: ErrorEvent) => e.preventDefault(); + window.addEventListener("error", swallow); + try { + const logging = new MockLogging(); + logging.errorReference = "evt-1"; + const router = createMemoryRouter([ + { + element: , + children: [{ path: "/", element: }], + }, + ]); + render( + + + + + + + + ); + expect( + screen.getByRole("heading", { name: "An unexpected error occurred" }) + ).toBeDefined(); + expect(screen.getByText(/Error reference/).textContent).toBe( + "Error reference: evt-1" + ); + expect(logging.errors).toHaveLength(1); + expect(logging.errors[0].message).toBe("Uncaught render error"); + expect((logging.errors[0].e as Error).message).toBe("boom"); + } finally { + window.removeEventListener("error", swallow); + } +}); diff --git a/src/RootLayout.tsx b/src/RootLayout.tsx new file mode 100644 index 000000000..918edb8d8 --- /dev/null +++ b/src/RootLayout.tsx @@ -0,0 +1,35 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { ErrorBoundary, UnexpectedErrorPage } from "@microbit/ui-patterns"; +import { useCallback } from "react"; +import { Outlet } from "react-router"; +import { useDeployment } from "./deployment"; +import { useLogging } from "./logging/logging-hooks"; + +/** + * The root route. An uncaught render error below it shows the error page, + * with the Sentry event id as the reference, rather than unmounting the app. + */ +const RootLayout = () => { + const logging = useLogging(); + const { supportLink } = useDeployment(); + const handleError = useCallback( + (error: unknown) => logging.error("Uncaught render error", error), + [logging] + ); + return ( + ( + + )} + > + + + ); +}; + +export default RootLayout; diff --git a/src/deployment/default/logging.ts b/src/deployment/default/logging.ts index 115d78ffe..79d1847e7 100644 --- a/src/deployment/default/logging.ts +++ b/src/deployment/default/logging.ts @@ -14,12 +14,17 @@ export class ConsoleLogging implements Logging { event(event: Event): void { console.log(event); } - error(message: string, e: unknown, context?: Record): void { + error( + message: string, + e: unknown, + context?: Record + ): string | undefined { if (context) { console.error(message, e, context); } else { console.error(message, e); } + return undefined; } log(e: any): void { console.log(e); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index ef12c5f4c..e040fd3d2 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -42,8 +42,12 @@ export class Logger implements Logging { this.sink.setUserProperty(name, value); } - error(message: string, e: unknown, context?: Record): void { - reportError(this.sentryDsn, message, e, context); + error( + message: string, + e: unknown, + context?: Record + ): string | undefined { + return reportError(this.sentryDsn, message, e, context); } log(v: unknown): void { diff --git a/src/logging/logging.ts b/src/logging/logging.ts index 3970cab50..83b2ca2d9 100644 --- a/src/logging/logging.ts +++ b/src/logging/logging.ts @@ -15,8 +15,15 @@ export interface Logging { /** * Report an error. `context` is attached to the Sentry event as extra * data; keep it to primitives and never include document text. + * + * Returns a reference for the report (the Sentry event id) when one was + * sent, for showing to the user so support can find the report. */ - error(message: string, e: unknown, context?: Record): void; + error( + message: string, + e: unknown, + context?: Record + ): string | undefined; log(e: any): void; /** * Set a GA4 user property — auto-attaches to every subsequent event diff --git a/src/logging/mock.ts b/src/logging/mock.ts index 4352b8581..33ace666b 100644 --- a/src/logging/mock.ts +++ b/src/logging/mock.ts @@ -14,12 +14,19 @@ export class MockLogging implements Logging { }> = []; logs: any[] = []; userProperties: Record = {}; + /** What error() returns, for tests of code that shows the reference. */ + errorReference: string | undefined = undefined; event(event: Event): void { this.events.push(event); } - error(message: string, e: unknown, context?: Record): void { + error( + message: string, + e: unknown, + context?: Record + ): string | undefined { this.errors.push({ message, e, context }); + return this.errorReference; } log(e: any): void { this.logs.push(e); diff --git a/src/logging/sentry.test.ts b/src/logging/sentry.test.ts index 50425e1b9..fbbd4bfe2 100644 --- a/src/logging/sentry.test.ts +++ b/src/logging/sentry.test.ts @@ -26,8 +26,14 @@ describe("reportError", () => { }); it("does nothing beyond console when Sentry is disabled", () => { - reportError(undefined, "Oops", new Error("boom")); + const reference = reportError(undefined, "Oops", new Error("boom")); expect(captureException).not.toHaveBeenCalled(); + expect(reference).toBeUndefined(); + }); + + it("returns the event id as the report reference", () => { + vi.mocked(captureException).mockReturnValueOnce("evt-1"); + expect(reportError(dsn, "Oops", new Error("boom"))).toBe("evt-1"); }); it("passes Error instances through unchanged", () => { diff --git a/src/logging/sentry.ts b/src/logging/sentry.ts index 0f14210b2..af5ecfc2d 100644 --- a/src/logging/sentry.ts +++ b/src/logging/sentry.ts @@ -52,14 +52,14 @@ export const reportError = ( message: string, e: unknown, context?: Record -): void => { +): string | undefined => { if (context) { console.error(message, e, context); } else { console.error(message, e); } if (!dsn) { - return; + return undefined; } try { sentryAddBreadcrumb({ @@ -69,9 +69,13 @@ export const reportError = ( }); const { error, extra } = toError(e); const combined = extra || context ? { ...extra, ...context } : undefined; - sentryCaptureException(error, combined ? { extra: combined } : undefined); + return sentryCaptureException( + error, + combined ? { extra: combined } : undefined + ); } catch (err) { console.error(err); + return undefined; } }; diff --git a/src/router.tsx b/src/router.tsx index e564fdc53..03f6f0645 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -3,8 +3,8 @@ * * SPDX-License-Identifier: MIT */ -import { createBrowserRouter, Outlet } from "react-router"; -import ErrorBoundary from "./common/ErrorBoundary"; +import { createBrowserRouter } from "react-router"; +import RootLayout from "./RootLayout"; import { basename, editorRoutePath } from "./urls"; import Workbench from "./workbench/Workbench"; @@ -14,12 +14,7 @@ export const createRouter = () => { id: "root", path: "", - // Without this an uncaught render error unmounts the whole app. - element: ( - - - - ), + element: , children: [ { path: editorRoutePath, element: }, // Deeper paths are the editor with no tab selected, as before.