From 20fc9295b177831cf74890173faed5792d8a0688 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 11 Sep 2026 17:52:31 +0000 Subject: [PATCH] Show the shared error page for uncaught render errors Takes @microbit/ui-patterns 0.6.1 and uses its ErrorBoundary and UnexpectedErrorPage at the root route. The react-router change had put the app's own ErrorBoundary there, whose inline "download your hex file" text assumes the editor is still on screen; full-page it is not. That boundary is unchanged around the sidebar panels, where a documentation tab failing to load (typically a blocked Sanity domain) still shows the inline message beside a working editor. The page shows an error reference so a support request can be matched to the Sentry report. Logging.error now returns that reference (the event id from captureException) when the error was sent, and the root layout passes it through to the page. The support link is the brand's; deployments without one get no support sentence, as with the help menu. --- package-lock.json | 8 ++--- package.json | 2 +- src/RootLayout.test.tsx | 55 +++++++++++++++++++++++++++++++ src/RootLayout.tsx | 35 ++++++++++++++++++++ src/deployment/default/logging.ts | 7 +++- src/logging/logger.ts | 8 +++-- src/logging/logging.ts | 9 ++++- src/logging/mock.ts | 9 ++++- src/logging/sentry.test.ts | 8 ++++- src/logging/sentry.ts | 10 ++++-- src/router.tsx | 11 ++----- 11 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 src/RootLayout.test.tsx create mode 100644 src/RootLayout.tsx 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.