From e8dd631b88aa2645c44f35ca9e86a79433061b4c Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 11 Sep 2026 16:43:39 +0000 Subject: [PATCH] ui-patterns: shared error pages with a support reference Lifts the full-page error views ml-trainer and classroom each carry into ui-patterns so the Python Editor can use them too and there is one place to maintain them. - ErrorPage: the shared layout, also for an app's own terminal states such as ml-trainer's stored-data version page. Content is vertically centred rather than a third of the way down, so it survives short viewports, and focus moves to the heading on mount so a screen reader user learns the page has been replaced. - UnexpectedErrorPage: apology, support link, optional error reference and a reload button. The reference is the Sentry event id, shown so a support request quoting it can be matched to the report, with a copy button because an event id is too long to retype reliably; this is the part classroom prototyped on its sentry-error-reference branch. - NotFoundPage: the unknown-URL page with a link home, with the link text overridable for apps that name themselves in it. - ErrorBoundary: catches render errors and renders a fallback the app supplies. Its onError callback is where the app reports the error and can return the reference for the fallback to show, which fills the logging gap in ml-trainer's boundary. Titles that were hardcoded English in both apps are messages here. Route-level errorElements stay app-side, as they need react-router. --- packages/ui-patterns/README.md | 12 ++ packages/ui-patterns/lang/ui.en.json | 32 ++++ packages/ui-patterns/src/ErrorBoundary.tsx | 61 +++++++ packages/ui-patterns/src/ErrorPage.tsx | 54 ++++++ packages/ui-patterns/src/NotFoundPage.tsx | 36 ++++ .../ui-patterns/src/UnexpectedErrorPage.tsx | 112 ++++++++++++ packages/ui-patterns/src/index.ts | 4 + .../stories/ErrorBoundary.stories.tsx | 54 ++++++ .../ui-patterns/stories/ErrorPage.stories.tsx | 38 +++++ .../stories/NotFoundPage.stories.tsx | 23 +++ .../stories/UnexpectedErrorPage.stories.tsx | 46 +++++ .../ui-patterns/tests/ErrorPages.test.tsx | 161 ++++++++++++++++++ 12 files changed, 633 insertions(+) create mode 100644 packages/ui-patterns/src/ErrorBoundary.tsx create mode 100644 packages/ui-patterns/src/ErrorPage.tsx create mode 100644 packages/ui-patterns/src/NotFoundPage.tsx create mode 100644 packages/ui-patterns/src/UnexpectedErrorPage.tsx create mode 100644 packages/ui-patterns/stories/ErrorBoundary.stories.tsx create mode 100644 packages/ui-patterns/stories/ErrorPage.stories.tsx create mode 100644 packages/ui-patterns/stories/NotFoundPage.stories.tsx create mode 100644 packages/ui-patterns/stories/UnexpectedErrorPage.stories.tsx create mode 100644 packages/ui-patterns/tests/ErrorPages.test.tsx diff --git a/packages/ui-patterns/README.md b/packages/ui-patterns/README.md index 582f55a..6dd3aba 100644 --- a/packages/ui-patterns/README.md +++ b/packages/ui-patterns/README.md @@ -28,6 +28,18 @@ blocks. pass a narrowed set where landing a user in a language silently would be wrong (e.g. incomplete native-app translations). +- **Error views** — the full-page fallbacks every app carries. + `UnexpectedErrorPage` says an unexpected error occurred, links to the + app's support site, offers a reload, and shows an optional error + reference (the Sentry event id) so a support request can be matched to + the report. `NotFoundPage` is the unknown-URL page with a link home. + Both sit on `ErrorPage`, the shared layout, which apps can use for their + own cases. `ErrorBoundary` catches render errors below it and renders a + fallback you supply; its `onError` callback is where the app reports the + error and can return the reference for the fallback to show. Route-level + `errorElement`s stay app-side (they need react-router's `useRouteError`) + and render the same views. + (`` needs nothing from this package: `@microbit/ui`'s `SharedUIProvider` keeps it in step with the locale automatically.) diff --git a/packages/ui-patterns/lang/ui.en.json b/packages/ui-patterns/lang/ui.en.json index fbc377c..53b08c6 100644 --- a/packages/ui-patterns/lang/ui.en.json +++ b/packages/ui-patterns/lang/ui.en.json @@ -1,4 +1,16 @@ { + "ui-patterns.copied-feedback": { + "defaultMessage": "Copied", + "description": "Accessible name of the copy button briefly after copying the error reference" + }, + "ui-patterns.copy-error-reference-action": { + "defaultMessage": "Copy error reference", + "description": "Accessible name of the button that copies the error reference" + }, + "ui-patterns.error-reference": { + "defaultMessage": "Error reference: {id}", + "description": "Shown on the unexpected error page. The id identifies the error report and can be quoted in support requests." + }, "ui-patterns.help-translate": { "defaultMessage": "Help translate", "description": "Language dialog link to the translation project" @@ -35,8 +47,28 @@ "defaultMessage": "Language not fully supported", "description": "Language support toast notification title" }, + "ui-patterns.not-found-home-link": { + "defaultMessage": "Go to home page", + "description": "Link text on the page not found page" + }, + "ui-patterns.not-found-title": { + "defaultMessage": "Page not found", + "description": "Title of the page shown for an unknown URL" + }, "ui-patterns.play-video": { "defaultMessage": "Play video: {title}", "description": "Accessible label for the play button shown over a video preview image; title is the video's title" + }, + "ui-patterns.reload-action": { + "defaultMessage": "Click to reload the page", + "description": "Button on the unexpected error page" + }, + "ui-patterns.support-request": { + "defaultMessage": "Please consider raising a support request.", + "description": "Text on the unexpected error page. The link opens the support site." + }, + "ui-patterns.unexpected-error-title": { + "defaultMessage": "An unexpected error occurred", + "description": "Title of the full-page view shown for an unrecoverable error" } } diff --git a/packages/ui-patterns/src/ErrorBoundary.tsx b/packages/ui-patterns/src/ErrorBoundary.tsx new file mode 100644 index 0000000..3ab1bea --- /dev/null +++ b/packages/ui-patterns/src/ErrorBoundary.tsx @@ -0,0 +1,61 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Component, ErrorInfo, ReactNode } from "react"; + +export interface ErrorBoundaryProps { + /** + * Called once for each error caught. Report the error here. Returning a + * string (typically the Sentry event id) passes it to the fallback as the + * reference to show the user. + */ + onError?: (error: unknown, errorInfo: ErrorInfo) => string | undefined | void; + /** + * Rendered in place of the children after an error. The reference is + * whatever `onError` returned, so it is undefined on the first render of + * the fallback and filled in on the next. + */ + fallback: (error: unknown, reference: string | undefined) => ReactNode; + children?: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error?: unknown; + reference?: string; +} + +/** + * Catches render errors below it. Pair it with react-router's + * `errorElement`, which also catches loader errors but not from inside a + * boundary like this one; both can render `UnexpectedErrorPage`. + */ +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { + const reference = this.props.onError?.(error, errorInfo); + if (reference) { + this.setState({ reference }); + } + } + + render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error, this.state.reference); + } + return this.props.children; + } +} diff --git a/packages/ui-patterns/src/ErrorPage.tsx b/packages/ui-patterns/src/ErrorPage.tsx new file mode 100644 index 0000000..ceebd01 --- /dev/null +++ b/packages/ui-patterns/src/ErrorPage.tsx @@ -0,0 +1,54 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Heading, VStack } from "@microbit/ui"; +import { ReactNode, useEffect, useRef } from "react"; + +export interface ErrorPageProps { + title: ReactNode; + children?: ReactNode; +} + +/** + * Full-page layout for terminal states: a centred heading with the + * explanation and actions beneath it. `UnexpectedErrorPage` and + * `NotFoundPage` use it; apps can use it for their own cases. + * + * Focus moves to the heading on mount. These pages replace whatever the user + * was doing, often without any interaction of theirs, and without this a + * screen reader user is left on a focus target that no longer exists. + */ +export const ErrorPage = ({ title, children }: ErrorPageProps) => { + const headingRef = useRef(null); + useEffect(() => { + headingRef.current?.focus(); + }, []); + return ( + + + {title} + + + {children} + + + ); +}; diff --git a/packages/ui-patterns/src/NotFoundPage.tsx b/packages/ui-patterns/src/NotFoundPage.tsx new file mode 100644 index 0000000..3e8e4e1 --- /dev/null +++ b/packages/ui-patterns/src/NotFoundPage.tsx @@ -0,0 +1,36 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Link } from "@microbit/ui"; +import { ReactNode } from "react"; +import { FormattedMessage } from "react-intl"; +import { ErrorPage } from "./ErrorPage"; +import { uiPatternsMessage } from "./messages"; + +export interface NotFoundPageProps { + /** The app's home page. A full navigation, which is fine for a dead end. */ + homeUrl: string; + /** Replaces the default "Go to home page" link text. */ + homeLinkText?: ReactNode; +} + +/** + * The page shown for a URL the app does not recognise. + */ +export const NotFoundPage = ({ homeUrl, homeLinkText }: NotFoundPageProps) => ( + + } + > + + {homeLinkText ?? ( + + )} + + +); diff --git a/packages/ui-patterns/src/UnexpectedErrorPage.tsx b/packages/ui-patterns/src/UnexpectedErrorPage.tsx new file mode 100644 index 0000000..0360634 --- /dev/null +++ b/packages/ui-patterns/src/UnexpectedErrorPage.tsx @@ -0,0 +1,112 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + Button, + Code, + ExternalLink, + HStack, + Icon, + IconButton, + Text, + useClipboard, +} from "@microbit/ui"; +import { ReactNode } from "react"; +import { RiCheckLine, RiFileCopyLine } from "react-icons/ri"; +import { FormattedMessage, useIntl } from "react-intl"; +import { ErrorPage } from "./ErrorPage"; +import { uiPatternsMessage } from "./messages"; + +export interface UnexpectedErrorPageProps { + /** Where "raising a support request" links to. */ + supportUrl: string; + /** + * Identifies the error report, typically the Sentry event id, so a support + * request quoting it can be matched to the report. Omit when the error was + * not reported. + */ + reference?: string; + /** Replaces the default action of reloading the page. */ + onReload?: () => void; + /** + * Extra content between the standard text and the reload button, for a + * recovery action the app can still offer. + */ + children?: ReactNode; +} + +const reloadPage = () => window.location.reload(); + +/** + * The page shown when the app cannot continue: an apology, a support link, + * the error reference and a reload button. + */ +export const UnexpectedErrorPage = ({ + supportUrl, + reference, + onReload = reloadPage, + children, +}: UnexpectedErrorPageProps) => ( + + } + > + + ( + + {chunks} + + ), + }} + /> + + {reference && } + {children} + + + + +); + +/** + * The reference with a copy button: an event id is too long to retype into + * a support form reliably. + */ +const ErrorReference = ({ reference }: { reference: string }) => { + const intl = useIntl(); + const { onCopy, hasCopied } = useClipboard(reference); + return ( + + + {reference} }} + /> + + + + + + ); +}; diff --git a/packages/ui-patterns/src/index.ts b/packages/ui-patterns/src/index.ts index 3f7d403..cbe58bd 100644 --- a/packages/ui-patterns/src/index.ts +++ b/packages/ui-patterns/src/index.ts @@ -7,7 +7,11 @@ * ui-patterns — higher-level patterns composed from @microbit/ui primitives, * shared across the micro:bit app family. */ +export * from "./ErrorBoundary"; +export * from "./ErrorPage"; export * from "./LanguageDialog"; +export * from "./NotFoundPage"; +export * from "./UnexpectedErrorPage"; export * from "./YoutubeVideoEmbed"; export * from "./get-default-language"; export * from "./languages"; diff --git a/packages/ui-patterns/stories/ErrorBoundary.stories.tsx b/packages/ui-patterns/stories/ErrorBoundary.stories.tsx new file mode 100644 index 0000000..f29d277 --- /dev/null +++ b/packages/ui-patterns/stories/ErrorBoundary.stories.tsx @@ -0,0 +1,54 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Button, Text, VStack } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { ErrorBoundary, UnexpectedErrorPage } from "../src"; + +const meta = { + title: "Patterns/ErrorBoundary", + component: ErrorBoundary, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const Crasher = () => { + const [crash, setCrash] = useState(false); + if (crash) { + throw new Error("Deliberate render error"); + } + return ( + + Press the button to throw during render. + + + ); +}; + +/** + * The boundary calls `onError` once with the error, and whatever it returns + * (here a fake event id, in an app the Sentry event id) becomes the reference + * the fallback shows. + */ +export const WithUnexpectedErrorPage: Story = { + args: { + onError: (error) => { + console.error(error); + return "5f1e7a2c9b3d4e6f8a0b1c2d3e4f5a6b"; + }, + fallback: (_error, reference) => ( + alert("reload")} + /> + ), + children: , + }, +}; diff --git a/packages/ui-patterns/stories/ErrorPage.stories.tsx b/packages/ui-patterns/stories/ErrorPage.stories.tsx new file mode 100644 index 0000000..32d5689 --- /dev/null +++ b/packages/ui-patterns/stories/ErrorPage.stories.tsx @@ -0,0 +1,38 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Button, Text } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ErrorPage } from "../src"; + +const meta = { + title: "Patterns/ErrorPage", + component: ErrorPage, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** + * The layout on its own, for an app's own terminal states. This is + * ml-trainer's pre-release page for a stored-data format change, which the + * apps that assert their IndexedDB schema on open all need. + */ +export const StorageVersionError: Story = { + args: { + title: "Breaking change to stored data", + children: ( + <> + + The storage format has changed in this pre-release version and the old + data format is not supported. + + + + ), + }, +}; diff --git a/packages/ui-patterns/stories/NotFoundPage.stories.tsx b/packages/ui-patterns/stories/NotFoundPage.stories.tsx new file mode 100644 index 0000000..deacb04 --- /dev/null +++ b/packages/ui-patterns/stories/NotFoundPage.stories.tsx @@ -0,0 +1,23 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { NotFoundPage } from "../src"; + +const meta = { + title: "Patterns/NotFoundPage", + component: NotFoundPage, + args: { homeUrl: "#" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +/** For apps that name themselves in the link, as ml-trainer does. */ +export const WithAppName: Story = { + args: { homeLinkText: "micro:bit CreateAI home page" }, +}; diff --git a/packages/ui-patterns/stories/UnexpectedErrorPage.stories.tsx b/packages/ui-patterns/stories/UnexpectedErrorPage.stories.tsx new file mode 100644 index 0000000..742f53c --- /dev/null +++ b/packages/ui-patterns/stories/UnexpectedErrorPage.stories.tsx @@ -0,0 +1,46 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Button } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UnexpectedErrorPage } from "../src"; + +const meta = { + title: "Patterns/UnexpectedErrorPage", + component: UnexpectedErrorPage, + args: { + supportUrl: "https://support.microbit.org/support/home/", + onReload: () => alert("reload"), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** The reference is the Sentry event id, for quoting in support requests. */ +export const WithReference: Story = { + args: { + reference: "5f1e7a2c9b3d4e6f8a0b1c2d3e4f5a6b", + }, +}; + +/** When the error was not reported there is no reference to show. */ +export const WithoutReference: Story = {}; + +/** + * The children slot is for a recovery action the app can still offer after a + * render error, e.g. the Python Editor's file system outlives a crashed UI so + * the project can still be downloaded. + */ +export const WithRecoveryAction: Story = { + args: { + reference: "5f1e7a2c9b3d4e6f8a0b1c2d3e4f5a6b", + children: ( + + ), + }, +}; diff --git a/packages/ui-patterns/tests/ErrorPages.test.tsx b/packages/ui-patterns/tests/ErrorPages.test.tsx new file mode 100644 index 0000000..801a37b --- /dev/null +++ b/packages/ui-patterns/tests/ErrorPages.test.tsx @@ -0,0 +1,161 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { SharedUIProvider } from "@microbit/ui"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ReactNode } from "react"; +import { IntlProvider } from "react-intl"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ErrorBoundary, NotFoundPage, UnexpectedErrorPage } from "../src"; + +afterEach(cleanup); +afterEach(() => { + vi.restoreAllMocks(); +}); + +const Providers = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const supportUrl = "https://support.example.org/"; + +describe("UnexpectedErrorPage", () => { + it("explains, links to support and offers a reload", async () => { + const user = userEvent.setup(); + const onReload = vi.fn(); + render( + , + { + wrapper: Providers, + }, + ); + expect( + screen.getByRole("heading", { name: "An unexpected error occurred" }), + ).toBeDefined(); + const link = screen.getByRole("link", { + name: /raising a support request/, + }); + expect(link.href).toBe(supportUrl); + expect(link.target).toBe("_blank"); + expect(screen.queryByText(/Error reference/)).toBeNull(); + await user.click( + screen.getByRole("button", { name: "Click to reload the page" }), + ); + expect(onReload).toHaveBeenCalledOnce(); + }); + + it("focuses the heading on mount", () => { + render(, { + wrapper: Providers, + }); + expect(document.activeElement).toBe( + screen.getByRole("heading", { name: "An unexpected error occurred" }), + ); + }); + + it("shows the reference with a copy button when given", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + render(, { + wrapper: Providers, + }); + expect(screen.getByText(/Error reference/).textContent).toBe( + "Error reference: abc123", + ); + const copy = screen.getByRole("button", { name: "Copy error reference" }); + await user.click(copy); + expect(writeText).toHaveBeenCalledWith("abc123"); + expect(copy.getAttribute("aria-label")).toBe("Copied"); + }); +}); + +describe("NotFoundPage", () => { + it("links home with the default text", () => { + render(, { wrapper: Providers }); + expect( + screen.getByRole("heading", { name: "Page not found" }), + ).toBeDefined(); + const link = screen.getByRole("link", { + name: "Go to home page", + }); + expect(link.getAttribute("href")).toBe("/home"); + }); + + it("takes app-specific link text", () => { + render(, { + wrapper: Providers, + }); + expect( + screen.getByRole("link", { name: "CreateAI home page" }), + ).toBeDefined(); + }); +}); + +const Thrower = (): ReactNode => { + throw new Error("boom"); +}; + +// React reports a caught render error to console.error and jsdom reports it +// again as a window error event; silence both for the throwing cases. +const expectRenderError = () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const swallow = (e: ErrorEvent) => e.preventDefault(); + window.addEventListener("error", swallow); + return () => window.removeEventListener("error", swallow); +}; + +describe("ErrorBoundary", () => { + it("renders children when nothing throws", () => { + render( +

fallback

}> +

content

+
, + ); + expect(screen.getByText("content")).toBeDefined(); + }); + + it("reports the error and passes the returned reference to the fallback", () => { + const restore = expectRenderError(); + const onError = vi.fn().mockReturnValue("ref-1"); + render( + ( +

+ {(error as Error).message} {reference} +

+ )} + > + +
, + ); + expect(onError).toHaveBeenCalledOnce(); + expect((onError.mock.calls[0][0] as Error).message).toBe("boom"); + expect(screen.getByText("boom ref-1")).toBeDefined(); + restore(); + }); + + it("renders the fallback without a reference when onError returns nothing", () => { + const restore = expectRenderError(); + render( + ( +

fallback {reference ?? "no-reference"}

+ )} + > + +
, + ); + expect(screen.getByText("fallback no-reference")).toBeDefined(); + restore(); + }); +});