Skip to content
Snippets Groups Projects
Commit a1419cab authored by Bálint Kléri's avatar Bálint Kléri Committed by Harumi Jang
Browse files

Issue #3461431 by balintbrews, bnjmnm, jessebaker, hooroomoo: Improve client side error handling

parent 63743938
No related branches found
No related tags found
1 merge request!164#3461431: Client-side error handling
Pipeline #254929 passed with warnings
Showing
with 674 additions and 188 deletions
......@@ -403,6 +403,30 @@ describe('General Experience Builder', {testIsolation: false}, () => {
cy.url().should('contain', `/xb/node/1/component/${uuid}`)
})
it('Handles and resets errors', () => {
cy.drupalLogin('xbUser', 'xbUser');
// Intercept the request to the preview endpoint and return a 418 status
// code. This will cause the error boundary to display an error message.
// Note the times: 1 option, which ensures the request is only intercepted
// once.
cy.intercept(
{ url: '**/api/preview/node/1', times: 1 },
{ statusCode: 418 }
);
cy.drupalRelativeURL('xb/node/1');
cy.findByTestId('error-alert')
.should('exist')
.invoke('text')
.should('include', 'An unexpected error has occurred');
// Click the reset button to clear the error, and confirm the error message
// is no longer present.
cy.findByTestId('error-reset').click();
cy.contains('An unexpected error has occurred').should('not.exist');
});
it('has the expected performance', () => {
cy.drupalLogin('xbUser', 'xbUser')
cy.intercept('POST', '**/api/preview/node/1').as('getPreview')
......
import React, { Suspense } from 'react';
import {
Await,
createBrowserRouter,
json,
RouterProvider,
} from 'react-router-dom';
import ErrorBoundary, {
RouteErrorBoundary,
RouteAsyncErrorBoundary,
} from '@/components/error/ErrorBoundary';
beforeEach(() => {
cy.on('uncaught:exception', (err, runnable) => {
// Uncaught exceptions cause Cypress to fail the test. Prevent this behavior.
return false;
});
});
const TroubleMaker = ({ shouldThrow }) => {
if (shouldThrow) {
throw new Error('Too many tabs, too little coffee');
} else {
return null;
}
};
describe('ErrorBoundary handles errors', () => {
it('displays an error only when an error is thrown', () => {
// Mount the <TroubleMaker /> test component inside our <ErrorBoundary />.
// No error is thrown yet, so we shouldn't see any error message.
cy.mount(
<ErrorBoundary>
<TroubleMaker />
</ErrorBoundary>
);
cy.contains('too little coffee').should('not.exist');
// Now mount again with an error thrown, and we should see the error message.
cy.mount(
<ErrorBoundary>
<TroubleMaker shouldThrow={true} />
</ErrorBoundary>
);
cy.findByRole('alert')
.invoke('text')
.should('include', 'An unexpected error has occurred.')
.should('include', 'Too many tabs, too little coffee')
.should('include', 'Try again');
});
it('displays custom props', () => {
cy.mount(
<ErrorBoundary
title='Unexpected decaf detected.'
resetButtonText='Brew again'
>
<TroubleMaker shouldThrow={true} />
</ErrorBoundary>
);
cy.findByRole('alert')
.invoke('text')
.should('include', 'Unexpected decaf detected.')
.should('include', 'Too many tabs, too little coffee')
.should('include', 'Brew again');
});
it('invokes callback to reset', () => {
const reset = cy.stub({ reset: () => {} }, 'reset').as('reset');
cy.mount(
<ErrorBoundary onReset={reset}>
<TroubleMaker shouldThrow={true} />
</ErrorBoundary>
);
cy.contains('Try again').click();
cy.get('@reset').should('be.calledOnce');
});
it('displays the right variant', () => {
cy.mount(
<ErrorBoundary variant='page'>
<TroubleMaker shouldThrow={true} />
</ErrorBoundary>
);
cy.findByTestId('error-page')
.should('exist')
.invoke('text')
.should('include', 'An unexpected error has occurred.')
.should('include', 'Too many tabs, too little coffee')
.should('include', 'Try again');
cy.mount(
<ErrorBoundary variant='card'>
<TroubleMaker shouldThrow={true} />
</ErrorBoundary>
);
cy.findByTestId('error-card')
.should('exist')
.invoke('text')
.should('include', 'An unexpected error has occurred.')
.should('include', 'Too many tabs, too little coffee')
.should('include', 'Try again');
cy.mount(
<ErrorBoundary variant='alert'>
<TroubleMaker shouldThrow={true} />
</ErrorBoundary>
);
cy.findByTestId('error-alert')
.should('exist')
.invoke('text')
.should('include', 'An unexpected error has occurred.')
.should('include', 'Too many tabs, too little coffee')
.should('include', 'Try again');
});
});
describe('RouteErrorBoundary handles errors', () => {
it('displays an error only when an error is thrown', () => {
// Let React Router know that the path is '/'.
window.history.pushState({}, null, '/');
// Mount a simple browser router with the <TroubleMaker /> test component
// in it, and with our <RouteErrorBoundary /> component as error element.
cy.mount(
<RouterProvider
router={createBrowserRouter([
{
path: '',
element: <TroubleMaker />,
errorElement: <RouteErrorBoundary />,
},
])}
/>
);
// No error is thrown yet, so we shouldn't see any error message.
cy.contains('too little coffee').should('not.exist');
// Now mount again with an error thrown, and we should see the error message.
cy.mount(
<RouterProvider
router={createBrowserRouter([
{
path: '',
element: <TroubleMaker shouldThrow={true} />,
errorElement: <RouteErrorBoundary />,
},
])}
/>
);
cy.findByRole('alert')
.invoke('text')
.should('include', 'An unexpected error has occurred in a route.')
.should('include', 'Too many tabs, too little coffee')
// Make sure there is no reset button. That is not supported in React
// Router's error element.
.should('not.include', 'Try again');
});
it('displays an error from a route error response', () => {
// Let React Router know that the path is '/'.
window.history.pushState({}, null, '/');
cy.mount(
<RouterProvider
router={createBrowserRouter([
{
path: '',
element: <></>,
errorElement: <RouteErrorBoundary />,
loader: () => {
throw json(
{},
{ status: 418, statusText: 'Unable to brew coffee' }
);
},
},
])}
/>
);
cy.findByRole('alert')
.invoke('text')
.should('include', 'An unexpected error has occurred in a route.')
.should('include', '418 Unable to brew coffee');
});
});
describe('RouteAsyncErrorBoundary handles errors', () => {
it('displays an error when deferred data loading fails', () => {
// Let React Router know that the path is '/'.
window.history.pushState({}, null, '/');
// Mount a simple browser router with React Router's <Await> inside where
// we reject the promise it awaits.
cy.mount(
<RouterProvider
router={createBrowserRouter([
{
path: '',
element: (
<Suspense>
<Await
resolve={Promise.reject('Too many tabs, too little coffee')}
errorElement={<RouteAsyncErrorBoundary />}
/>
</Suspense>
),
},
])}
/>
);
cy.findByRole('alert')
.invoke('text')
.should('include', 'An unexpected async error has occurred in a route.')
.should('include', 'Too many tabs, too little coffee')
// Make sure there is no reset button. That is not supported in React
// Router's error element.
.should('not.include', 'Try again');
});
});
......@@ -12,6 +12,7 @@ import {
} from '@/features/ui/uiSlice';
import Canvas from '@/features/canvas/Canvas';
import { ZoomInIcon } from '@radix-ui/react-icons';
import ErrorBoundary from '@/components/error/ErrorBoundary';
import PrimaryMenubar from '@/components/sidebar/primary/PrimaryMenubar';
import Topbar from '@/components/topbar/Topbar';
import useSyncComponentId from '@/hooks/useSyncComponentId';
......@@ -26,65 +27,73 @@ const App = () => {
useSyncComponentId();
return (
<div
className={clsx(styles.app, {
[styles.rightSideBarOpen]: contextualPanelOpen,
})}
>
<Canvas />
<Layout />
<Topbar />
<PrimaryMenubar />
<Outlet />
<div className={styles.canvasControls}>
<Card size="1">
<Flex align="center" gap="3">
<Text size="1">x: {Math.round(canvasViewPort.x)}px, </Text>
<Text size="1">y: {Math.round(canvasViewPort.y)}px</Text>
<Select.Root
defaultValue="100%"
value={
scaleValues.find((sv) => sv.scale === canvasViewPort.scale)
?.percent
}
onValueChange={(value) =>
dispatch(
setCanvasViewPort({
scale: scaleValues.find((sv) => value === sv.percent)
?.scale,
}),
)
}
>
<Select.Trigger variant="ghost">
<Flex as="span" align="center" gap="2">
<ZoomInIcon />
{
scaleValues.find((sv) => sv.scale === canvasViewPort.scale)
?.percent
}
</Flex>
</Select.Trigger>
<Select.Content>
{scaleValues.map((sv) => (
<Select.Item key={sv.scale} value={sv.percent}>
{sv.percent}
</Select.Item>
))}
</Select.Content>
</Select.Root>
<Button
onClick={() =>
dispatch(setCanvasViewPort({ x: 4000, y: 4500, scale: 1 }))
}
>
Debug: scroll to middle
</Button>
</Flex>
</Card>
<ErrorBoundary variant="page">
<div
className={clsx(styles.app, {
[styles.rightSideBarOpen]: contextualPanelOpen,
})}
>
<Canvas />
<ErrorBoundary
variant="alert"
title="An unexpected error has occurred while fetching layouts."
>
<Layout />
</ErrorBoundary>
<Topbar />
<PrimaryMenubar />
<Outlet />
<div className={styles.canvasControls}>
<Card size="1">
<Flex align="center" gap="3">
<Text size="1">x: {Math.round(canvasViewPort.x)}px, </Text>
<Text size="1">y: {Math.round(canvasViewPort.y)}px</Text>
<Select.Root
defaultValue="100%"
value={
scaleValues.find((sv) => sv.scale === canvasViewPort.scale)
?.percent
}
onValueChange={(value) =>
dispatch(
setCanvasViewPort({
scale: scaleValues.find((sv) => value === sv.percent)
?.scale,
}),
)
}
>
<Select.Trigger variant="ghost">
<Flex as="span" align="center" gap="2">
<ZoomInIcon />
{
scaleValues.find(
(sv) => sv.scale === canvasViewPort.scale,
)?.percent
}
</Flex>
</Select.Trigger>
<Select.Content>
{scaleValues.map((sv) => (
<Select.Item key={sv.scale} value={sv.percent}>
{sv.percent}
</Select.Item>
))}
</Select.Content>
</Select.Root>
<Button
onClick={() =>
dispatch(setCanvasViewPort({ x: 4000, y: 4500, scale: 1 }))
}
>
Debug: scroll to middle
</Button>
</Flex>
</Card>
</div>
<div id="menuBarContainer" className="menuBarContainer"></div>
</div>
<div id="menuBarContainer" className="menuBarContainer"></div>
</div>
</ErrorBoundary>
);
};
......
import ContextualPanel from '@/components/panel/ContextualPanel';
import { defer, createBrowserRouter, RouterProvider } from 'react-router-dom';
import App from '@/app/App';
import { RouteErrorBoundary } from '@/components/error/ErrorBoundary';
const getHTML = async (componentId: string) => {
return new Promise((resolve) => {
......@@ -20,6 +21,7 @@ const AppRoutes: React.FC<AppRoutesInterface> = ({ basePath }) => {
{
path: '',
element: <App />,
errorElement: <RouteErrorBoundary />,
children: [
{
path: '/component/:componentId',
......
import React from 'react';
import { useErrorBoundary } from 'react-error-boundary';
import { useGetDummyPropsFormQuery } from '@/services/dummyPropsForm';
import hyperscriptify from '@/local_packages/hyperscriptify';
import twigToJSXComponentMap from '@/components/form/twig-to-jsx-component-map.js';
......@@ -36,11 +37,17 @@ const DummyPropsEditFormRenderer: React.FC<DummyPropsEditFormRendererProps> = (
props,
) => {
const { dynamicStaticCardQueryString } = props;
const { data } = useGetDummyPropsFormQuery(dynamicStaticCardQueryString);
const { data, error } = useGetDummyPropsFormQuery(
dynamicStaticCardQueryString,
);
const { showBoundary } = useErrorBoundary();
const [jsxFormContent, setJsxFormContent] = useState(null);
const formRef = useRef(null);
useEffect(() => {
if (error) {
showBoundary(error);
}
if (!data) {
return;
}
......@@ -62,7 +69,7 @@ const DummyPropsEditFormRenderer: React.FC<DummyPropsEditFormRendererProps> = (
)
: null,
);
}, [data]);
}, [data, error, showBoundary]);
// Any time this form changes, process it through Drupal behaviors the same
// way it would be if it were added to the DOM by Drupal AJAX. This allows
......@@ -86,13 +93,17 @@ const DummyPropsEditFormRenderer: React.FC<DummyPropsEditFormRendererProps> = (
const DummyPropsEditForm: React.FC<DummyPropsEditFormProps> = () => {
const model = useAppSelector(selectModel);
const layout = useAppSelector(selectLayout);
const { data: components } = useGetComponentsQuery();
const { data: components, error } = useGetComponentsQuery();
const { showBoundary } = useErrorBoundary();
const selectedComponent = useAppSelector(selectSelectedComponent);
const [dynamicStaticCardQueryString, setDynamicStaticCardQueryString] =
useState('');
useEffect(() => {
if (error) {
showBoundary(error);
}
if (!components || !selectedComponent) {
return;
}
......@@ -168,7 +179,7 @@ const DummyPropsEditForm: React.FC<DummyPropsEditFormProps> = () => {
selected: selectedComponent,
});
setDynamicStaticCardQueryString(`?${query.toString()}`);
}, [components, selectedComponent, layout, model]);
}, [components, error, showBoundary, selectedComponent, layout, model]);
return (
dynamicStaticCardQueryString && (
......
import { AlertDialog, Box, Button, Flex } from '@radix-ui/themes';
import { ExclamationTriangleIcon, ReloadIcon } from '@radix-ui/react-icons';
const DEFAULT_TITLE = 'An unexpected error has occurred.';
const DEFAULT_RESET_BUTTON_TEXT = 'Try again';
const ErrorAlert: React.FC<{
title?: string;
error?: string;
resetErrorBoundary?: () => void;
resetButtonText?: string;
}> = ({
title = DEFAULT_TITLE,
error,
resetErrorBoundary,
resetButtonText = DEFAULT_RESET_BUTTON_TEXT,
}) => (
<AlertDialog.Root defaultOpen>
<AlertDialog.Content data-testid="error-alert" maxWidth="520px">
<AlertDialog.Title>
<Flex align="center" gap="3">
<Flex flexShrink="0" flexGrow="0" align="center">
<ExclamationTriangleIcon width="24" height="24" />
</Flex>
{title}
</Flex>
</AlertDialog.Title>
{error && (
<AlertDialog.Description size="2">{error}</AlertDialog.Description>
)}
{resetErrorBoundary && (
<Box mt="4">
<AlertDialog.Action>
<Button
data-testid="error-reset"
variant="solid"
onClick={resetErrorBoundary}
>
<ReloadIcon />
{resetButtonText}
</Button>
</AlertDialog.Action>
</Box>
)}
</AlertDialog.Content>
</AlertDialog.Root>
);
export default ErrorAlert;
import React, { useEffect } from 'react';
import {
isRouteErrorResponse,
useAsyncError,
useRouteError,
} from 'react-router-dom';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
import ErrorAlert from '@/components/error/ErrorAlert';
import ErrorCard from '@/components/error/ErrorCard';
import ErrorPage from '@/components/error/ErrorPage';
import { usePostLogEntryMutation } from '@/services/log';
/**
* Error boundary component that catches errors in its child component tree.
* @see https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
* @see https://github.com/bvaughn/react-error-boundary
*/
const ErrorBoundary: React.FC<{
title?: string;
resetButtonText?: string;
onReset?: () => void;
variant?: 'page' | 'card' | 'alert';
children: React.ReactNode;
}> = ({ title, resetButtonText, onReset, variant = 'card', children }) => {
const [postLogEntryMutation] = usePostLogEntryMutation();
return (
<ReactErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => {
if (variant === 'alert') {
return (
<ErrorAlert
title={title}
error={error.message || error.error}
resetErrorBoundary={resetErrorBoundary}
resetButtonText={resetButtonText}
/>
);
}
const Wrapper = variant === 'page' ? ErrorPage : React.Fragment;
return (
<Wrapper>
<ErrorCard
title={title}
error={error.message || error.error}
resetErrorBoundary={resetErrorBoundary}
resetButtonText={resetButtonText}
/>
</Wrapper>
);
}}
onError={postLogEntryMutation}
onReset={onReset}
>
{children}
</ReactErrorBoundary>
);
};
export default ErrorBoundary;
const getRouteErrorMessage = (error: unknown): string => {
if (isRouteErrorResponse(error)) {
return `${error.status} ${error.statusText}`;
} else if (error instanceof Error) {
return error.message;
} else if (typeof error === 'string') {
return error;
} else {
console.error(error);
return 'Unknown error';
}
};
/**
* Error element for React Router.
* @see https://reactrouter.com/en/main/route/error-element
*/
export const RouteErrorBoundary: React.FC = () => {
const error = useRouteError();
const [postLogEntryMutation] = usePostLogEntryMutation();
useEffect(() => {
error && postLogEntryMutation(error as Error, {});
}, [error, postLogEntryMutation]);
return (
<ErrorPage>
<ErrorCard
title="An unexpected error has occurred in a route."
error={getRouteErrorMessage(error)}
/>
</ErrorPage>
);
};
/**
* Async error element for React Router's deferred data loading.
* @see https://reactrouter.com/en/main/guides/deferred
* @see https://reactrouter.com/en/main/components/await
*/
export const RouteAsyncErrorBoundary: React.FC = () => {
const error = useAsyncError();
const [postLogEntryMutation] = usePostLogEntryMutation();
useEffect(() => {
error && postLogEntryMutation(error as Error, {});
}, [error, postLogEntryMutation]);
return (
<ErrorCard
title="An unexpected async error has occurred in a route."
error={getRouteErrorMessage(error)}
/>
);
};
import { Card, Heading, Text, Flex, Box, Button } from '@radix-ui/themes';
import { ExclamationTriangleIcon, ReloadIcon } from '@radix-ui/react-icons';
const DEFAULT_TITLE = 'An unexpected error has occurred.';
const DEFAULT_RESET_BUTTON_TEXT = 'Try again';
const ErrorCard: React.FC<{
title?: string;
error?: string;
resetErrorBoundary?: () => void;
resetButtonText?: string;
}> = ({
title = DEFAULT_TITLE,
error,
resetErrorBoundary,
resetButtonText = DEFAULT_RESET_BUTTON_TEXT,
}) => (
<Box data-testid="error-card" maxWidth="520px" m="4">
<Card role="alert" variant="surface">
<Flex p="4" direction="column" gap="4" align="start">
<Flex align="center" gap="3">
<Flex flexShrink="0" flexGrow="0" align="center">
<ExclamationTriangleIcon width="24" height="24" />
</Flex>
<Heading trim="both" size="4" weight="medium">
{title}
</Heading>
</Flex>
{error && <Text as="p">{error}</Text>}
{resetErrorBoundary && (
<Button data-testid="error-reset" onClick={resetErrorBoundary}>
<ReloadIcon />
{resetButtonText}
</Button>
)}
</Flex>
</Card>
</Box>
);
export default ErrorCard;
import { Flex } from '@radix-ui/themes';
const ErrorPage: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
<Flex
data-testid="error-page"
align="center"
justify="center"
height="100vh"
style={{ backgroundColor: 'var(--canvas-bg)' }}
>
{children}
</Flex>
);
export default ErrorPage;
import { isRouteErrorResponse, useRouteError } from 'react-router-dom';
import { Card, Heading, Text, Flex } from '@radix-ui/themes';
import type React from 'react';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
function errorMessage(error: unknown): string {
if (isRouteErrorResponse(error)) {
return `${error.status} ${error.statusText}`;
} else if (error instanceof Error) {
return error.message;
} else if (typeof error === 'string') {
return error;
} else {
console.error(error);
return 'Unknown error';
}
}
const ErrorPage: React.FC = () => {
const error = errorMessage(useRouteError());
console.error(error);
// @todo Awaiting design: This HTML is not final/approved, just hashed together quickly.
return (
<Flex align="center" justify="center" height="100vh" id="error-page">
<Card id="error-page" variant="surface" size="4">
<Heading as="h1" mb="2">
<ExclamationTriangleIcon width="16" height="16" /> Oops!
</Heading>
<Text as="p">Sorry, an unexpected error has occurred.</Text>
<Text as="p">{error}</Text>
</Card>
</Flex>
);
};
export default ErrorPage;
......@@ -22,7 +22,9 @@ import {
} from '@/features/ui/uiSlice';
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import { useLoaderData, Await } from 'react-router-dom';
import ErrorBoundary from '@/components/error/ErrorBoundary';
import DummyPropsEditForm from '@/components/DummyPropsEditForm';
import { RouteAsyncErrorBoundary } from '@/components/error/ErrorBoundary';
import clsx from 'clsx';
interface ContextualPanelProps {}
......@@ -56,86 +58,90 @@ const ContextualPanel: React.FC<ContextualPanelProps> = () => {
<Cross1Icon />
</IconButton>
</Flex>
<Grid height="100%" rows="1" columns="1" gap="2" p="0">
<Inset
clip="padding-box"
side="left"
pb="current"
className={styles.cardInset}
>
<Grid height="100%" p="0" columns="12px 1fr" gap="0">
<Box height="100%">
<Flex
justify="center"
align="center"
className={styles.handleContainer}
>
<DragHandleVerticalIcon className={styles.handleIcon} />
</Flex>
</Box>
<Suspense fallback={<p>Loading component...</p>}>
<Await
// POC: In AppRoutes.tsx we are loading async data into data.html - this will only show the rest
// of the markup once that promise is resolved. The purple <h4> renders the data as an example.
// @ts-ignore - PoC only, if real would need TS definitions.
resolve={data.html}
errorElement={<p>Error loading Component ID!</p>}
>
{(html) => (
<>
<Box>
<Flex justify="center" align="center" my="2">
<SegmentedControl.Root
defaultValue="settings"
onValueChange={setActivePanel}
<ErrorBoundary>
<Grid height="100%" rows="1" columns="1" gap="2" p="0">
<Inset
clip="padding-box"
side="left"
pb="current"
className={styles.cardInset}
>
<Grid height="100%" p="0" columns="12px 1fr" gap="0">
<Box height="100%">
<Flex
justify="center"
align="center"
className={styles.handleContainer}
>
<DragHandleVerticalIcon className={styles.handleIcon} />
</Flex>
</Box>
<Suspense fallback={<p>Loading component...</p>}>
<Await
// POC: In AppRoutes.tsx we are loading async data into data.html - this will only show the rest
// of the markup once that promise is resolved. The purple <h4> renders the data as an example.
// @ts-ignore - PoC only, if real would need TS definitions.
resolve={data.html}
errorElement={<RouteAsyncErrorBoundary />}
>
{(html) => (
<>
<Box>
<Flex justify="center" align="center" my="2">
<SegmentedControl.Root
defaultValue="settings"
onValueChange={setActivePanel}
>
<SegmentedControl.Item value="settings">
Settings
</SegmentedControl.Item>
<SegmentedControl.Item value="pageSettings">
Page Data
</SegmentedControl.Item>
</SegmentedControl.Root>
</Flex>
<Separator
orientation="horizontal"
size="4"
className={clsx('separator', styles.separator)}
/>
<ScrollArea
type="always"
size="1"
scrollbars="vertical"
style={{ height: 700 }}
>
<SegmentedControl.Item value="settings">
Settings
</SegmentedControl.Item>
<SegmentedControl.Item value="pageSettings">
Page Data
</SegmentedControl.Item>
</SegmentedControl.Root>
</Flex>
<Separator
orientation="horizontal"
size="4"
className={clsx('separator', styles.separator)}
/>
<ScrollArea
type="always"
size="1"
scrollbars="vertical"
style={{ height: 700 }}
>
<Box pt="3">
{activePanel === 'settings' && (
<>
<DummyPropsEditForm />
<Heading
as="h4"
size="1"
style={{ color: '#8A00E6' }}
>
POC: {html}
</Heading>
</>
)}
{activePanel === 'pageSettings' && (
<Text size="1">
Styles for...{selectedComponent}
</Text>
)}
</Box>
</ScrollArea>
</Box>
</>
)}
</Await>
</Suspense>
</Grid>
</Inset>
</Grid>
<Box pt="3">
{activePanel === 'settings' && (
<>
<ErrorBoundary title="An unexpected error has occurred while rendering the component's form.">
<DummyPropsEditForm />
</ErrorBoundary>
<Heading
as="h4"
size="1"
style={{ color: '#8A00E6' }}
>
POC: {html}
</Heading>
</>
)}
{activePanel === 'pageSettings' && (
<Text size="1">
Styles for...{selectedComponent}
</Text>
)}
</Box>
</ScrollArea>
</Box>
</>
)}
</Await>
</Suspense>
</Grid>
</Inset>
</Grid>
</ErrorBoundary>
</Box>
);
};
......
......@@ -5,6 +5,7 @@ import clsx from 'clsx';
import Preview from '@/features/layout/preview/Preview';
import { useHotkeys } from 'react-hotkeys-hook';
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import ErrorBoundary from '@/components/error/ErrorBoundary';
import {
selectCanvasViewPort,
canvasViewPortZoomIn,
......@@ -251,7 +252,12 @@ const Canvas = () => {
}}
>
<div className={styles.previewsContainer} ref={previewsContainerRef}>
<Preview />
<ErrorBoundary
title="An unexpected error has occurred while rendering preview."
variant="alert"
>
<Preview />
</ErrorBoundary>
</div>
</div>
</div>
......
import { useEffect } from 'react';
import { useErrorBoundary } from 'react-error-boundary';
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import { useGetLayoutByIdQuery } from '@/services/layout';
import { setLayoutModel } from './layoutModelSlice';
......@@ -7,9 +8,13 @@ import { selectEntityId } from '@/features/configuration/configurationSlice';
const Layout = () => {
const dispatch = useAppDispatch();
const entityId = useAppSelector(selectEntityId);
const { data: fetchedLayout } = useGetLayoutByIdQuery(entityId);
const { data: fetchedLayout, error } = useGetLayoutByIdQuery(entityId);
const { showBoundary } = useErrorBoundary();
useEffect(() => {
if (error) {
showBoundary(error);
}
if (fetchedLayout) {
dispatch(
setLayoutModel({
......@@ -19,7 +24,7 @@ const Layout = () => {
}),
);
}
}, [fetchedLayout, dispatch]);
}, [fetchedLayout, error, showBoundary, dispatch]);
return null;
};
......
import type React from 'react';
import { useEffect, useState } from 'react';
import { useErrorBoundary } from 'react-error-boundary';
import { useAppSelector } from '@/app/hooks';
import {
......@@ -33,6 +34,7 @@ const Preview: React.FC<PreviewProps> = () => {
const model = useAppSelector(selectModel);
const [frameSrcDoc, setFrameSrcDoc] = useState('');
const [postPreview, { isLoading }] = usePostPreviewMutation();
const { showBoundary } = useErrorBoundary();
useEffect(() => {
const sendPreviewRequest = async () => {
......@@ -42,14 +44,13 @@ const Preview: React.FC<PreviewProps> = () => {
// Handle the successful response here
setFrameSrcDoc(result.html);
} catch (err) {
// Handle the error here
console.error(err); // Do something with the error
showBoundary(err);
}
};
if (initialized === true) {
sendPreviewRequest().then(() => {});
}
}, [layout, model, postPreview, initialized]);
}, [layout, model, postPreview, initialized, showBoundary]);
return (
<>
......
......@@ -5,6 +5,7 @@ import AppRoutes from '@/app/AppRoutes';
import { makeStore } from '@/app/store';
import '@radix-ui/themes/styles.css';
import { Theme } from '@radix-ui/themes';
import ErrorBoundary from '@/components/error/ErrorBoundary';
import type { AppConfiguration } from '@/features/configuration/configurationSlice';
import './index.css';
......@@ -58,9 +59,11 @@ if (container) {
panelBackground="solid"
appearance="light"
>
<Provider store={makeStore({ configuration: appConfiguration })}>
<AppRoutes basePath={routerRoot} />
</Provider>
<ErrorBoundary variant="page">
<Provider store={makeStore({ configuration: appConfiguration })}>
<AppRoutes basePath={routerRoot} />
</Provider>
</ErrorBoundary>
</Theme>
</React.StrictMode>,
);
......
import type { ErrorInfo } from 'react';
import type { ErrorResponse } from 'react-router-dom';
const logApi = {
// Dummy service to log errors. Only a placeholder for now that mimics how
// other services are defined and used.
// @todo Implement in #3467844: Log client-side errors
// (https://www.drupal.org/project/experience_builder/issues/3467844)
usePostLogEntryMutation: () => [
// @see https://github.com/bvaughn/react-error-boundary?tab=readme-ov-file#logging-errors-with-onerror
(error: Error | ErrorResponse, info?: ErrorInfo) => {
console.error(error);
},
],
};
export const { usePostLogEntryMutation } = logApi;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment