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.