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
12 changes: 12 additions & 0 deletions packages/ui-patterns/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

(`<html lang>` needs nothing from this package: `@microbit/ui`'s
`SharedUIProvider` keeps it in step with the locale automatically.)

Expand Down
32 changes: 32 additions & 0 deletions packages/ui-patterns/lang/ui.en.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 <link>raising a support request</link>.",
"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"
}
}
61 changes: 61 additions & 0 deletions packages/ui-patterns/src/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
}
54 changes: 54 additions & 0 deletions packages/ui-patterns/src/ErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLHeadingElement>(null);
useEffect(() => {
headingRef.current?.focus();
}, []);
return (
<VStack
as="main"
minH="100vh"
w="100%"
px={4}
py={8}
gap={10}
justifyContent="center"
alignItems="center"
bgColor="whitesmoke"
>
<Heading
ref={headingRef}
as="h1"
tabIndex={-1}
textAlign="center"
outline="none"
>
{title}
</Heading>
<VStack gap={3} maxW="md" textAlign="center">
{children}
</VStack>
</VStack>
);
};
36 changes: 36 additions & 0 deletions packages/ui-patterns/src/NotFoundPage.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<ErrorPage
title={
<FormattedMessage {...uiPatternsMessage("ui-patterns.not-found-title")} />
}
>
<Link href={homeUrl} color="brand.600">
{homeLinkText ?? (
<FormattedMessage
{...uiPatternsMessage("ui-patterns.not-found-home-link")}
/>
)}
</Link>
</ErrorPage>
);
112 changes: 112 additions & 0 deletions packages/ui-patterns/src/UnexpectedErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<ErrorPage
title={
<FormattedMessage
{...uiPatternsMessage("ui-patterns.unexpected-error-title")}
/>
}
>
<Text>
<FormattedMessage
{...uiPatternsMessage("ui-patterns.support-request")}
values={{
link: (chunks: ReactNode) => (
<ExternalLink href={supportUrl} color="brand.600">
{chunks}
</ExternalLink>
),
}}
/>
</Text>
{reference && <ErrorReference reference={reference} />}
{children}
<Text>
<Button variant="primary" onPress={onReload}>
<FormattedMessage {...uiPatternsMessage("ui-patterns.reload-action")} />
</Button>
</Text>
</ErrorPage>
);

/**
* 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 (
<HStack gap={1} justifyContent="center" flexWrap="wrap">
<Text>
<FormattedMessage
{...uiPatternsMessage("ui-patterns.error-reference")}
values={{ id: <Code>{reference}</Code> }}
/>
</Text>
<IconButton
variant="ghost"
size="sm"
onPress={onCopy}
aria-label={intl.formatMessage(
uiPatternsMessage(
hasCopied
? "ui-patterns.copied-feedback"
: "ui-patterns.copy-error-reference-action",
),
)}
>
<Icon as={hasCopied ? RiCheckLine : RiFileCopyLine} />
</IconButton>
</HStack>
);
};
4 changes: 4 additions & 0 deletions packages/ui-patterns/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
54 changes: 54 additions & 0 deletions packages/ui-patterns/stories/ErrorBoundary.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ErrorBoundary>;
export default meta;

type Story = StoryObj<typeof meta>;

const Crasher = () => {
const [crash, setCrash] = useState(false);
if (crash) {
throw new Error("Deliberate render error");
}
return (
<VStack p={8} alignItems="flex-start" gap={4}>
<Text>Press the button to throw during render.</Text>
<Button variant="secondary" onPress={() => setCrash(true)}>
Crash
</Button>
</VStack>
);
};

/**
* 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) => (
<UnexpectedErrorPage
supportUrl="https://support.microbit.org/support/home/"
reference={reference}
onReload={() => alert("reload")}
/>
),
children: <Crasher />,
},
};
Loading