diff --git a/.changeset/feat-error-boundaries-5047.md b/.changeset/feat-error-boundaries-5047.md new file mode 100644 index 000000000000..a60ce37a940f --- /dev/null +++ b/.changeset/feat-error-boundaries-5047.md @@ -0,0 +1,5 @@ +--- +'asyncapi-website': patch +--- + +feat(ui): add a top-level React `ErrorBoundary` and wrap every `Layout` return tree in it, so a render error in any descendant component (broken MDX page, future page, etc.) no longer tears down the whole site — it shows a recoverable fallback instead. diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx new file mode 100644 index 000000000000..c40515f45e6a --- /dev/null +++ b/components/ErrorBoundary.tsx @@ -0,0 +1,54 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +/** + * Top-level ErrorBoundary for the AsyncAPI website. + * + * Wrapping the whole app in at the layout level means a render + * error in any descendant component (e.g. a future page, a broken MDX block) + * no longer tears down the whole site — it just shows a small recoverable + * fallback so navigation still works. + * + * See: https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary + */ +export default class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // Surface the error to the browser console; in production a real Sentry + // hook would go here. + // eslint-disable-next-line no-console + console.error('[ErrorBoundary] Caught a render error:', error, info); + } + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + return ( +
+

Something went wrong.

+

Please refresh the page, or use the navigation to continue.

+
+ ); + } + return this.props.children; + } +} diff --git a/components/layout/Layout.tsx b/components/layout/Layout.tsx index f7ed8782d7c5..4b1a9d1d574b 100644 --- a/components/layout/Layout.tsx +++ b/components/layout/Layout.tsx @@ -6,6 +6,7 @@ import type { IPost, IPosts } from '@/types/post'; import BlogContext from '../../context/BlogContext'; import { getAllPosts, getDocBySlug, getPostBySlug } from '../../utils/api'; +import ErrorBoundary from '../ErrorBoundary'; import BlogLayout from './BlogLayout'; import DocsLayout from './DocsLayout'; import GenericPostLayout from './GenericPostLayout'; @@ -28,9 +29,11 @@ export default function Layout({ children }: ILayoutProps): React.JSX.Element { return (
- - {children} - + + + {children} + +
); } @@ -39,24 +42,36 @@ export default function Layout({ children }: ILayoutProps): React.JSX.Element { return (
- - {children} - + + + {children} + +
); } if (pathname === '/blog') { return (
- {children} + + {children} +
); } const post = getPostBySlug(pathname); if (post) { - return {children}; + return ( + + {children} + + ); } - return
{children}
; + return ( +
+ {children} +
+ ); }