Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions src/RootLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -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: <RootLayout />,
children: [{ path: "/", element: <Thrower /> }],
},
]);
render(
<IntlProvider locale="en">
<SharedUIProvider>
<LoggingProvider value={logging}>
<RouterProvider router={router} />
</LoggingProvider>
</SharedUIProvider>
</IntlProvider>
);
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);
}
});
35 changes: 35 additions & 0 deletions src/RootLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ErrorBoundary
onError={handleError}
fallback={(_error, reference) => (
<UnexpectedErrorPage supportUrl={supportLink} reference={reference} />
)}
>
<Outlet />
</ErrorBoundary>
);
};

export default RootLayout;
7 changes: 6 additions & 1 deletion src/deployment/default/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ export class ConsoleLogging implements Logging {
event(event: Event): void {
console.log(event);
}
error(message: string, e: unknown, context?: Record<string, unknown>): void {
error(
message: string,
e: unknown,
context?: Record<string, unknown>
): string | undefined {
if (context) {
console.error(message, e, context);
} else {
console.error(message, e);
}
return undefined;
}
log(e: any): void {
console.log(e);
Expand Down
8 changes: 6 additions & 2 deletions src/logging/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@ export class Logger implements Logging {
this.sink.setUserProperty(name, value);
}

error(message: string, e: unknown, context?: Record<string, unknown>): void {
reportError(this.sentryDsn, message, e, context);
error(
message: string,
e: unknown,
context?: Record<string, unknown>
): string | undefined {
return reportError(this.sentryDsn, message, e, context);
}

log(v: unknown): void {
Expand Down
9 changes: 8 additions & 1 deletion src/logging/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): void;
error(
message: string,
e: unknown,
context?: Record<string, unknown>
): string | undefined;
log(e: any): void;
/**
* Set a GA4 user property — auto-attaches to every subsequent event
Expand Down
9 changes: 8 additions & 1 deletion src/logging/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,19 @@ export class MockLogging implements Logging {
}> = [];
logs: any[] = [];
userProperties: Record<string, string> = {};
/** 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<string, unknown>): void {
error(
message: string,
e: unknown,
context?: Record<string, unknown>
): string | undefined {
this.errors.push({ message, e, context });
return this.errorReference;
}
log(e: any): void {
this.logs.push(e);
Expand Down
8 changes: 7 additions & 1 deletion src/logging/sentry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
10 changes: 7 additions & 3 deletions src/logging/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@ export const reportError = (
message: string,
e: unknown,
context?: Record<string, unknown>
): void => {
): string | undefined => {
if (context) {
console.error(message, e, context);
} else {
console.error(message, e);
}
if (!dsn) {
return;
return undefined;
}
try {
sentryAddBreadcrumb({
Expand All @@ -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;
}
};

Expand Down
11 changes: 3 additions & 8 deletions src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -14,12 +14,7 @@ export const createRouter = () =>
{
id: "root",
path: "",
// Without this an uncaught render error unmounts the whole app.
element: (
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
),
element: <RootLayout />,
children: [
{ path: editorRoutePath, element: <Workbench /> },
// Deeper paths are the editor with no tab selected, as before.
Expand Down