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

@ -2,6 +2,7 @@ import { AppRouter } from '@/app/components/AppRouter';
import { ApolloDevLogEffect } from '@/debug/components/ApolloDevLogEffect';
import { RecoilDebugObserverEffect } from '@/debug/components/RecoilDebugObserver';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { AppRootErrorFallback } from '@/error-handler/components/AppRootErrorFallback';
import { ExceptionHandlerProvider } from '@/error-handler/components/ExceptionHandlerProvider';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { i18n } from '@lingui/core';
@ -18,7 +19,10 @@ export const App = () => {
return (
<RecoilRoot>
<RecoilURLSyncJSON location={{ part: 'queryParams' }}>
<AppErrorBoundary>
<AppErrorBoundary
resetOnLocationChange={false}
FallbackComponent={AppRootErrorFallback}
>
<I18nProvider i18n={i18n}>
<RecoilDebugObserverEffect />
<ApolloDevLogEffect />

View File

@ -1,8 +1,8 @@
import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
import { ApolloProvider } from '@/apollo/components/ApolloProvider';
import { GotoHotkeysEffectsProvider } from '@/app/effect-components/GotoHotkeysEffectsProvider';
import { PageChangeEffect } from '@/app/effect-components/PageChangeEffect';
import { AuthProvider } from '@/auth/components/AuthProvider';
import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
import { ChromeExtensionSidecarEffect } from '@/chrome-extension-sidecar/components/ChromeExtensionSidecarEffect';
import { ChromeExtensionSidecarProvider } from '@/chrome-extension-sidecar/components/ChromeExtensionSidecarProvider';
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';

View File

@ -1,7 +1,7 @@
import { useRecoilValue } from 'recoil';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { ClientConfigError } from '@/error-handler/components/ClientConfigError';
import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback';
export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
children,
@ -14,7 +14,13 @@ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
if (!isLoaded) return null;
return isErrored && error instanceof Error ? (
<ClientConfigError error={error} />
<AppFullScreenErrorFallback
error={error}
resetErrorBoundary={() => {
window.location.reload();
}}
title="Unable to Reach Back-end"
/>
) : (
children
);

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;
};

View File

@ -1,6 +1,8 @@
import { AuthModal } from '@/auth/components/AuthModal';
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback';
import { AppPageErrorFallback } from '@/error-handler/components/AppPageErrorFallback';
import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu';
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
import { MobileNavigationBar } from '@/navigation/components/MobileNavigationBar';
@ -82,51 +84,52 @@ export const DefaultLayout = () => {
`}
/>
<StyledLayout>
{!showAuthModal && (
<>
<CommandMenuRouter />
<KeyboardShortcutMenu />
</>
)}
<StyledPageContainer
animate={{
marginLeft:
isSettingsPage && !isMobile && !useShowFullScreen
? (windowsWidth -
(OBJECT_SETTINGS_WIDTH +
NAV_DRAWER_WIDTHS.menu.desktop.expanded +
64)) /
2
: 0,
}}
transition={{ duration: theme.animation.duration.normal }}
>
{showAuthModal ? (
<StyledAppNavigationDrawerMock />
) : useShowFullScreen ? null : (
<StyledAppNavigationDrawer />
)}
{showAuthModal ? (
<>
<SignInBackgroundMockPage />
<AnimatePresence mode="wait">
<LayoutGroup>
<AuthModal>
<Outlet />
</AuthModal>
</LayoutGroup>
</AnimatePresence>
</>
) : (
<StyledMainContainer>
<AppErrorBoundary>
<Outlet />
</AppErrorBoundary>
</StyledMainContainer>
)}
</StyledPageContainer>
{isMobile && <MobileNavigationBar />}
<AppErrorBoundary FallbackComponent={AppFullScreenErrorFallback}>
<StyledPageContainer
animate={{
marginLeft:
isSettingsPage && !isMobile && !useShowFullScreen
? (windowsWidth -
(OBJECT_SETTINGS_WIDTH +
NAV_DRAWER_WIDTHS.menu.desktop.expanded +
64)) /
2
: 0,
}}
transition={{ duration: theme.animation.duration.normal }}
>
{!showAuthModal && (
<>
<CommandMenuRouter />
<KeyboardShortcutMenu />
</>
)}
{showAuthModal ? (
<StyledAppNavigationDrawerMock />
) : useShowFullScreen ? null : (
<StyledAppNavigationDrawer />
)}
{showAuthModal ? (
<>
<SignInBackgroundMockPage />
<AnimatePresence mode="wait">
<LayoutGroup>
<AuthModal>
<Outlet />
</AuthModal>
</LayoutGroup>
</AnimatePresence>
</>
) : (
<StyledMainContainer>
<AppErrorBoundary FallbackComponent={AppPageErrorFallback}>
<Outlet />
</AppErrorBoundary>
</StyledMainContainer>
)}
</StyledPageContainer>
{isMobile && <MobileNavigationBar />}
</AppErrorBoundary>
</StyledLayout>
</>
);