From 692e08f0d4fe5bce7dea294c32cc12eb239041e9 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sat, 22 Mar 2025 09:19:08 +0100 Subject: [PATCH] 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) image Route level (ex throw in CommandMenu) image Page level (ex throw in RecordTable) image Manually Triggered (clientConfig, ex when backend is not up) image --- .../src/modules/app/components/App.tsx | 6 +- .../app/components/AppRouterProviders.tsx | 2 +- .../components/ClientConfigProvider.tsx | 10 +- .../components/AppErrorBoundary.tsx | 34 +++-- .../components/AppFullScreenErrorFallback.tsx | 34 +++++ .../components/AppPageErrorFallback.tsx | 27 ++++ .../components/AppRootErrorFallback.tsx | 131 ++++++++++++++++++ .../components/ClientConfigError.tsx | 41 ------ .../components/GenericErrorFallback.tsx | 64 --------- .../internal/AppErrorBoundaryEffect.tsx | 22 +++ .../components/internal/AppErrorDisplay.tsx | 34 +++++ .../types/AppErrorDisplayProps.ts | 5 + .../layout/page/components/DefaultLayout.tsx | 93 +++++++------ 13 files changed, 341 insertions(+), 162 deletions(-) create mode 100644 packages/twenty-front/src/modules/error-handler/components/AppFullScreenErrorFallback.tsx create mode 100644 packages/twenty-front/src/modules/error-handler/components/AppPageErrorFallback.tsx create mode 100644 packages/twenty-front/src/modules/error-handler/components/AppRootErrorFallback.tsx delete mode 100644 packages/twenty-front/src/modules/error-handler/components/ClientConfigError.tsx delete mode 100644 packages/twenty-front/src/modules/error-handler/components/GenericErrorFallback.tsx create mode 100644 packages/twenty-front/src/modules/error-handler/components/internal/AppErrorBoundaryEffect.tsx create mode 100644 packages/twenty-front/src/modules/error-handler/components/internal/AppErrorDisplay.tsx create mode 100644 packages/twenty-front/src/modules/error-handler/types/AppErrorDisplayProps.ts diff --git a/packages/twenty-front/src/modules/app/components/App.tsx b/packages/twenty-front/src/modules/app/components/App.tsx index 77064d34a..a753f5b5b 100644 --- a/packages/twenty-front/src/modules/app/components/App.tsx +++ b/packages/twenty-front/src/modules/app/components/App.tsx @@ -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 ( - + diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx index 633af6248..16eb64bde 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx index d363c191e..f88b9652f 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx @@ -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 = ({ children, @@ -14,7 +14,13 @@ export const ClientConfigProvider: React.FC = ({ if (!isLoaded) return null; return isErrored && error instanceof Error ? ( - + { + window.location.reload(); + }} + title="Unable to Reach Back-end" + /> ) : ( children ); diff --git a/packages/twenty-front/src/modules/error-handler/components/AppErrorBoundary.tsx b/packages/twenty-front/src/modules/error-handler/components/AppErrorBoundary.tsx index a00f61fa6..8317cfbbb 100644 --- a/packages/twenty-front/src/modules/error-handler/components/AppErrorBoundary.tsx +++ b/packages/twenty-front/src/modules/error-handler/components/AppErrorBoundary.tsx @@ -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; + 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 ( ( + <> + {resetOnLocationChange && ( + + )} + + + )} onError={handleError} onReset={handleReset} > diff --git a/packages/twenty-front/src/modules/error-handler/components/AppFullScreenErrorFallback.tsx b/packages/twenty-front/src/modules/error-handler/components/AppFullScreenErrorFallback.tsx new file mode 100644 index 000000000..089027f49 --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/components/AppFullScreenErrorFallback.tsx @@ -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 ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/error-handler/components/AppPageErrorFallback.tsx b/packages/twenty-front/src/modules/error-handler/components/AppPageErrorFallback.tsx new file mode 100644 index 000000000..ee0031000 --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/components/AppPageErrorFallback.tsx @@ -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 ( + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/error-handler/components/AppRootErrorFallback.tsx b/packages/twenty-front/src/modules/error-handler/components/AppRootErrorFallback.tsx new file mode 100644 index 000000000..3479fdb3e --- /dev/null +++ b/packages/twenty-front/src/modules/error-handler/components/AppRootErrorFallback.tsx @@ -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 ( + + + + + + + + + {title} + {error.message} + + + + Reload + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/error-handler/components/ClientConfigError.tsx b/packages/twenty-front/src/modules/error-handler/components/ClientConfigError.tsx deleted file mode 100644 index 47141a532..000000000 --- a/packages/twenty-front/src/modules/error-handler/components/ClientConfigError.tsx +++ /dev/null @@ -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 ( - - - - ); -}; diff --git a/packages/twenty-front/src/modules/error-handler/components/GenericErrorFallback.tsx b/packages/twenty-front/src/modules/error-handler/components/GenericErrorFallback.tsx deleted file mode 100644 index 2d2e8efeb..000000000 --- a/packages/twenty-front/src/modules/error-handler/components/GenericErrorFallback.tsx +++ /dev/null @@ -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 ( - - {!hidePageHeader && } - - - - - - - {title} - - - {error.message} - - -