Improve AppError boundaries (#11107)

## What

This PR aims to make sure all application exceptions are captured
through react-error-boundaries

Once merged we will have:
- Root Level: AppErrorBoundary at the highest level (full screen) ==>
this one needs to be working in any case, not relying on Theme, was not
working
- Route Level: AppErrorBoundary in DefaultLayout (full screen) ==> this
was missing and it seems that error are not propagated outside of the
router, making errors triggered in CommandMenu or NavigationDrawer
missing
- Page Level: AppErrorBoundary in DefaultLayout write around the Page
itself (lower than CommandMenu + NavigationDrawer)
- Manually triggered: example in ClientConfigProvider

## Screenshots

App level (ex throw in IconsProvider)
<img width="1512" alt="image"
src="https://github.com/user-attachments/assets/18a14815-a203-4edf-b931-43068c3436ec"
/>

Route level (ex throw in CommandMenu)
<img width="1512" alt="image"
src="https://github.com/user-attachments/assets/ca066627-14c7-438e-a432-f0999a1f3b84"
/>

Page level (ex throw in RecordTable)
<img width="1512" alt="image"
src="https://github.com/user-attachments/assets/ffeaa935-02af-4762-8859-7a0ccf8b77e1"
/>

Manually Triggered (clientConfig, ex when backend is not up)
<img width="1512" alt="image"
src="https://github.com/user-attachments/assets/062d6d84-097a-4ed9-b6ce-763b8c27c659"
/>
This commit is contained in:
Charles Bochet
2025-03-22 09:19:08 +01:00
committed by GitHub
parent c50cdd9510
commit 692e08f0d4
13 changed files with 341 additions and 162 deletions

View File

@ -1,25 +1,43 @@
import { AppErrorBoundaryEffect } from '@/error-handler/components/internal/AppErrorBoundaryEffect';
import * as Sentry from '@sentry/react';
import { ErrorInfo, ReactNode } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { GenericErrorFallback } from '@/error-handler/components/GenericErrorFallback';
type AppErrorBoundaryProps = {
children: ReactNode;
FallbackComponent: React.ComponentType<FallbackProps>;
resetOnLocationChange?: boolean;
};
export const AppErrorBoundary = ({ children }: { children: ReactNode }) => {
const handleError = (_error: Error, _info: ErrorInfo) => {
Sentry.captureException(_error, (scope) => {
scope.setExtras({ _info });
export const AppErrorBoundary = ({
children,
FallbackComponent,
resetOnLocationChange = true,
}: AppErrorBoundaryProps) => {
const handleError = (error: Error, info: ErrorInfo) => {
Sentry.captureException(error, (scope) => {
scope.setExtras({ info });
return scope;
});
};
// TODO: Implement a better reset strategy, hard reload for now
const handleReset = () => {
window.location.reload();
};
return (
<ErrorBoundary
FallbackComponent={GenericErrorFallback}
FallbackComponent={({ error, resetErrorBoundary }) => (
<>
{resetOnLocationChange && (
<AppErrorBoundaryEffect resetErrorBoundary={resetErrorBoundary} />
)}
<FallbackComponent
error={error}
resetErrorBoundary={resetErrorBoundary}
/>
</>
)}
onError={handleError}
onReset={handleReset}
>

View File

@ -0,0 +1,34 @@
import { AppErrorDisplay } from '@/error-handler/components/internal/AppErrorDisplay';
import { AppErrorDisplayProps } from '@/error-handler/types/AppErrorDisplayProps';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import styled from '@emotion/styled';
type AppFullScreenErrorFallbackProps = AppErrorDisplayProps;
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.noisy};
box-sizing: border-box;
display: flex;
height: 100vh;
width: 100vw;
padding-top: ${({ theme }) => theme.spacing(3)};
padding-left: ${({ theme }) => theme.spacing(3)};
`;
export const AppFullScreenErrorFallback = ({
error,
resetErrorBoundary,
title = 'Sorry, something went wrong',
}: AppFullScreenErrorFallbackProps) => {
return (
<StyledContainer>
<PageBody>
<AppErrorDisplay
error={error}
resetErrorBoundary={resetErrorBoundary}
title={title}
/>
</PageBody>
</StyledContainer>
);
};

View File

@ -0,0 +1,27 @@
import { AppErrorDisplay } from '@/error-handler/components/internal/AppErrorDisplay';
import { AppErrorDisplayProps } from '@/error-handler/types/AppErrorDisplayProps';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
type AppPageErrorFallbackProps = AppErrorDisplayProps;
export const AppPageErrorFallback = ({
error,
resetErrorBoundary,
title = 'Sorry, something went wrong',
}: AppPageErrorFallbackProps) => {
return (
<PageContainer>
<PageHeader />
<PageBody>
<AppErrorDisplay
error={error}
resetErrorBoundary={resetErrorBoundary}
title={title}
/>
</PageBody>
</PageContainer>
);
};

View File

@ -0,0 +1,131 @@
import { AppErrorDisplayProps } from '@/error-handler/types/AppErrorDisplayProps';
import styled from '@emotion/styled';
import LightNoise from '@ui/theme/assets/light-noise.png';
import { motion } from 'framer-motion';
import { GRAY_SCALE, IconReload } from 'twenty-ui';
type AppRootErrorFallbackProps = AppErrorDisplayProps;
const StyledContainer = styled.div`
background: url(${LightNoise.toString()});
box-sizing: border-box;
display: flex;
height: 100vh;
width: 100vw;
padding: 12px;
`;
const StyledPanel = styled.div`
background: ${GRAY_SCALE.gray0};
border: 1px solid ${GRAY_SCALE.gray20};
border-radius: 8px;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
width: 100%;
`;
const StyledEmptyContainer = styled(motion.div)`
align-items: center;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 24px;
justify-content: center;
text-align: center;
`;
const StyledImageContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
position: relative;
`;
const StyledBackgroundImage = styled.img`
max-height: 160px;
max-width: 160px;
`;
const StyledInnerImage = styled.img`
max-height: 130px;
position: absolute;
max-width: 130px;
`;
const StyledEmptyTextContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: 8px;
justify-content: center;
text-align: center;
width: 100%;
`;
const StyledEmptyTitle = styled.div`
color: ${GRAY_SCALE.gray60};
font-size: 1.23rem;
font-weight: 600;
`;
const StyledEmptySubTitle = styled.div`
color: ${GRAY_SCALE.gray50};
font-size: 0.92rem;
font-weight: 400;
line-height: 1.5;
max-height: 2.8em;
overflow: hidden;
width: 50%;
`;
const StyledButton = styled.button`
align-items: center;
background: ${GRAY_SCALE.gray0};
border: 1px solid ${GRAY_SCALE.gray20};
color: ${GRAY_SCALE.gray60};
border-radius: 8px;
cursor: pointer;
display: flex;
padding: 8px 16px;
padding: 8px;
`;
const StyledIcon = styled(IconReload)`
color: ${GRAY_SCALE.gray60};
margin-right: 8px;
`;
export const AppRootErrorFallback = ({
error,
resetErrorBoundary,
title = 'Sorry, something went wrong',
}: AppRootErrorFallbackProps) => {
return (
<StyledContainer>
<StyledPanel>
<StyledEmptyContainer>
<StyledImageContainer>
<StyledBackgroundImage
src="/images/placeholders/background/error_index_bg.png"
alt="Background"
/>
<StyledInnerImage
src="/images/placeholders/moving-image/error_index.png"
alt="Inner"
/>
</StyledImageContainer>
<StyledEmptyTextContainer>
<StyledEmptyTitle>{title}</StyledEmptyTitle>
<StyledEmptySubTitle>{error.message}</StyledEmptySubTitle>
</StyledEmptyTextContainer>
<StyledButton onClick={resetErrorBoundary}>
<StyledIcon size={16} />
Reload
</StyledButton>
</StyledEmptyContainer>
</StyledPanel>
</StyledContainer>
);
};

View File

@ -1,41 +0,0 @@
import styled from '@emotion/styled';
import { MOBILE_VIEWPORT } from 'twenty-ui';
import { GenericErrorFallback } from './GenericErrorFallback';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.noisy};
box-sizing: border-box;
display: flex;
height: 100dvh;
width: 100%;
padding-top: ${({ theme }) => theme.spacing(3)};
padding-left: ${({ theme }) => theme.spacing(3)};
padding-bottom: 0;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: 0;
padding-bottom: ${({ theme }) => theme.spacing(3)};
}
`;
type ClientConfigErrorProps = {
error: Error;
};
export const ClientConfigError = ({ error }: ClientConfigErrorProps) => {
// TODO: Implement a better loading strategy
const handleReset = () => {
window.location.reload();
};
return (
<StyledContainer>
<GenericErrorFallback
error={error}
resetErrorBoundary={handleReset}
title="Unable to Reach Back-end"
hidePageHeader
/>
</StyledContainer>
);
};

View File

@ -1,64 +0,0 @@
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import { useEffect, useState } from 'react';
import { FallbackProps } from 'react-error-boundary';
import { useLocation } from 'react-router-dom';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
Button,
IconRefresh,
} from 'twenty-ui';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type GenericErrorFallbackProps = FallbackProps & {
title?: string;
hidePageHeader?: boolean;
};
export const GenericErrorFallback = ({
error,
resetErrorBoundary,
title = 'Sorry, something went wrong',
hidePageHeader = false,
}: GenericErrorFallbackProps) => {
const location = useLocation();
const [previousLocation] = useState(location);
useEffect(() => {
if (!isDeeplyEqual(previousLocation, location)) {
resetErrorBoundary();
}
}, [previousLocation, location, resetErrorBoundary]);
return (
<PageContainer>
{!hidePageHeader && <PageHeader />}
<PageBody>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="errorIndex" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
{title}
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
{error.message}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<Button
Icon={IconRefresh}
title="Reload"
variant={'secondary'}
onClick={resetErrorBoundary}
/>
</AnimatedPlaceholderEmptyContainer>
</PageBody>
</PageContainer>
);
};

View File

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import { FallbackProps } from 'react-error-boundary';
import { useLocation } from 'react-router-dom';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type AppErrorBoundaryEffectProps = Pick<FallbackProps, 'resetErrorBoundary'>;
export const AppErrorBoundaryEffect = ({
resetErrorBoundary,
}: AppErrorBoundaryEffectProps) => {
const location = useLocation();
const [previousLocation] = useState(location);
useEffect(() => {
if (!isDeeplyEqual(previousLocation, location)) {
resetErrorBoundary();
}
}, [previousLocation, location, resetErrorBoundary]);
return <></>;
};

View File

@ -0,0 +1,34 @@
import { AppErrorDisplayProps } from '@/error-handler/types/AppErrorDisplayProps';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
Button,
IconRefresh,
} from 'twenty-ui';
export const AppErrorDisplay = ({
error,
resetErrorBoundary,
title = 'Sorry, something went wrong',
}: AppErrorDisplayProps) => {
return (
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="errorIndex" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>{title}</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
{error.message}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<Button
Icon={IconRefresh}
title="Reload"
variant={'secondary'}
onClick={resetErrorBoundary}
/>
</AnimatedPlaceholderEmptyContainer>
);
};

View File

@ -0,0 +1,5 @@
import { FallbackProps } from 'react-error-boundary';
export type AppErrorDisplayProps = FallbackProps & {
title?: string;
};