Address PR review comments on error boundaries

Guard ErrorBoundary against a falsy thrown value: track a separate
hasError flag and accept the caught value as unknown, so throwing null,
0, or "" still renders the fallback instead of looping back into the
failing subtree.

Add role="alert" to InlineError and ErrorState so assistive tech
announces errors that appear dynamically after a fetch or query failure.

Fix the RecentUpdatesSection doc example, which showed onRetry={reset}
with @throwOnFieldError even though reset cannot clear a field error;
use a reload and point to the retry table.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-15 11:20:15 +02:00
parent 943b0ea226
commit 68338fb4ae
4 changed files with 41 additions and 28 deletions

View File

@@ -33,34 +33,37 @@ import { Component, type ErrorInfo, type ReactNode } from "react";
export interface ErrorBoundaryProps {
children: ReactNode;
// A node, or a render function that receives the caught error + a reset fn.
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
onError?: (error: Error, info: ErrorInfo) => void;
// A node, or a render function that receives the caught value + a reset fn.
// `unknown` because anything can be thrown, not just an Error.
fallback?: ReactNode | ((error: unknown, reset: () => void) => ReactNode);
onError?: (error: unknown, info: ErrorInfo) => void;
}
interface ErrorBoundaryState {
error: Error | null;
// Tracked separately from `error` so a falsy thrown value (null, 0, "") still
// renders the fallback instead of looping back into the failing subtree.
hasError: boolean;
error: unknown;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
componentDidCatch(error: unknown, info: ErrorInfo) {
this.props.onError?.(error, info);
}
reset = () => this.setState({ error: null });
reset = () => this.setState({ hasError: false, error: null });
render() {
const { error } = this.state;
if (error) {
if (this.state.hasError) {
const { fallback } = this.props;
if (typeof fallback === "function") {
return fallback(error, this.reset);
return fallback(this.state.error, this.reset);
}
return fallback ?? null;
}
@@ -188,8 +191,15 @@ export function RecentUpdatesSection({ trustCenterKey }: Props) {
const { t } = useTranslation();
return (
<ErrorBoundary
fallback={(_, reset) => (
<InlineError message={t("errors.inline.message")} onRetry={reset} retryLabel={t("errors.inline.retry")} />
// This section reads a field of the preloaded page query — there is no
// local refetch, so recover with a reload, not the boundary's `reset`
// (see "Retrying: reset vs refetch vs reload" below).
fallback={(
<InlineError
message={t("errors.inline.message")}
retryLabel={t("errors.inline.retry")}
onRetry={() => window.location.reload()}
/>
)}
>
<RecentUpdatesSectionContent trustCenterKey={trustCenterKey} />

View File

@@ -16,37 +16,40 @@ import { Component, type ErrorInfo, type ReactNode } from "react";
export interface ErrorBoundaryProps {
children: ReactNode;
// A node, or a render function that receives the caught error + a reset fn.
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
onError?: (error: Error, info: ErrorInfo) => void;
// A node, or a render function that receives the caught value + a reset fn.
// The value is `unknown` because anything can be thrown, not just an Error.
fallback?: ReactNode | ((error: unknown, reset: () => void) => ReactNode);
onError?: (error: unknown, info: ErrorInfo) => void;
}
interface ErrorBoundaryState {
error: Error | null;
// Tracked separately from `error` so a falsy thrown value (null, 0, "") still
// renders the fallback instead of looping back into the failing subtree.
hasError: boolean;
error: unknown;
}
// The single reusable error boundary primitive (the sanctioned use of a class).
// Generic — works at bootstrap, route, section, or component level; only the
// placement and the `fallback` differ. See contrib/claude/error-handling.md.
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
componentDidCatch(error: unknown, info: ErrorInfo) {
this.props.onError?.(error, info);
}
reset = () => this.setState({ error: null });
reset = () => this.setState({ hasError: false, error: null });
render() {
const { error } = this.state;
if (error) {
if (this.state.hasError) {
const { fallback } = this.props;
if (typeof fallback === "function") {
return fallback(error, this.reset);
return fallback(this.state.error, this.reset);
}
return fallback ?? null;
}

View File

@@ -39,7 +39,7 @@ export function ErrorState({ code, title, description, actions, fullPage, classN
const slots = errorState({ fullPage });
return (
<div className={slots.root({ className })}>
<div role="alert" className={slots.root({ className })}>
<div className={slots.block()}>
<div className={slots.content()}>
{code && (

View File

@@ -35,7 +35,7 @@ export function InlineError({ layout, message, onRetry, retryLabel = "Retry", cl
const slots = inlineError({ layout });
return (
<div className={slots.root({ className })}>
<div role="alert" className={slots.root({ className })}>
<Text size={2} color="neutral" className={slots.message()}>{message}</Text>
{onRetry && (
<Button size={2} variant="soft" color="neutral" onClick={onRetry}>