-
Notifications
You must be signed in to change notification settings - Fork 2.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Display a generic fallback component when initial config load fails #8588
Changes from 2 commits
552d6d1
ae2f193
b004845
7f9fce1
9ada829
4d1dd8a
c9f9250
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,43 @@ | ||
import { useRecoilValue } from 'recoil'; | ||
|
||
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState'; | ||
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; | ||
import { GenericErrorFallback } from '@/error-handler/components/GenericErrorFallback'; | ||
import styled from '@emotion/styled'; | ||
import { MOBILE_VIEWPORT } from 'twenty-ui'; | ||
|
||
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)}; | ||
} | ||
`; | ||
|
||
export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({ | ||
children, | ||
}) => { | ||
const isClientConfigLoaded = useRecoilValue(isClientConfigLoadedState); | ||
const { isLoaded, isErrored } = useRecoilValue(clientConfigApiStatusState); | ||
|
||
if (isLoaded && isErrored) { | ||
return ( | ||
<StyledContainer> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you please extract this styled container into its own component next to GenericErrorFallback? Eg. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, that's a good idea! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks! Just to be clear I didn't mean just the styled container but do a big wraper component. I think my comment wasn't clear |
||
<GenericErrorFallback | ||
error={new Error('Failed to fetch')} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Generic 'Failed to fetch' error may not be descriptive enough of the actual error that occurred. Consider passing the actual error from the API call. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @khuddite Agree with the AI comment, passing the error message from the API call would make sense (add errorMessage in your state?) |
||
resetErrorBoundary={() => {}} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Empty resetErrorBoundary callback means users can't retry when the error occurs. Consider implementing a retry mechanism. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Didn't test that but yes we should be able to reload from an error page See also #5027 @ehconitin will work on this now so it might be fixed as part of his PR |
||
title="Unable to Reach Back-end" | ||
isInitialFetch | ||
/> | ||
</StyledContainer> | ||
); | ||
} | ||
|
||
return isClientConfigLoaded ? <>{children}</> : <></>; | ||
return isLoaded ? <>{children}</> : null; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Consider showing a loading state instead of null while isLoaded is false There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would |
||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,8 +3,8 @@ import { authProvidersState } from '@/client-config/states/authProvidersState'; | |
import { billingState } from '@/client-config/states/billingState'; | ||
import { captchaProviderState } from '@/client-config/states/captchaProviderState'; | ||
import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState'; | ||
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; | ||
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState'; | ||
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState'; | ||
import { isDebugModeState } from '@/client-config/states/isDebugModeState'; | ||
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; | ||
import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState'; | ||
|
@@ -27,8 +27,8 @@ export const ClientConfigProviderEffect = () => { | |
const setSupportChat = useSetRecoilState(supportChatState); | ||
|
||
const setSentryConfig = useSetRecoilState(sentryConfigState); | ||
const [isClientConfigLoaded, setIsClientConfigLoaded] = useRecoilState( | ||
isClientConfigLoadedState, | ||
const [clientConfigApiStatus, setClientConfigApiStatus] = useRecoilState( | ||
clientConfigApiStatusState, | ||
); | ||
|
||
const setCaptchaProvider = useSetRecoilState(captchaProviderState); | ||
|
@@ -37,42 +37,62 @@ export const ClientConfigProviderEffect = () => { | |
|
||
const setApiConfig = useSetRecoilState(apiConfigState); | ||
|
||
const { data, loading } = useGetClientConfigQuery({ | ||
skip: isClientConfigLoaded, | ||
const { data, loading, error } = useGetClientConfigQuery({ | ||
skip: clientConfigApiStatus.isLoaded, | ||
}); | ||
|
||
useEffect(() => { | ||
if (!loading && isDefined(data?.clientConfig)) { | ||
setIsClientConfigLoaded(true); | ||
setAuthProviders({ | ||
google: data?.clientConfig.authProviders.google, | ||
microsoft: data?.clientConfig.authProviders.microsoft, | ||
password: data?.clientConfig.authProviders.password, | ||
magicLink: false, | ||
sso: data?.clientConfig.authProviders.sso, | ||
}); | ||
setIsDebugMode(data?.clientConfig.debugMode); | ||
setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled); | ||
setIsSignInPrefilled(data?.clientConfig.signInPrefilled); | ||
setIsSignUpDisabled(data?.clientConfig.signUpDisabled); | ||
|
||
setBilling(data?.clientConfig.billing); | ||
setSupportChat(data?.clientConfig.support); | ||
|
||
setSentryConfig({ | ||
dsn: data?.clientConfig?.sentry?.dsn, | ||
release: data?.clientConfig?.sentry?.release, | ||
environment: data?.clientConfig?.sentry?.environment, | ||
}); | ||
|
||
setCaptchaProvider({ | ||
provider: data?.clientConfig?.captcha?.provider, | ||
siteKey: data?.clientConfig?.captcha?.siteKey, | ||
}); | ||
|
||
setChromeExtensionId(data?.clientConfig?.chromeExtensionId); | ||
setApiConfig(data?.clientConfig?.api); | ||
if (loading) return; | ||
setClientConfigApiStatus((currentStatus) => ({ | ||
...currentStatus, | ||
isLoaded: true, | ||
})); | ||
Comment on lines
+46
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Setting isLoaded:true before checking for errors could cause race conditions. Move this after error checks. |
||
|
||
if (error instanceof Error) { | ||
setClientConfigApiStatus((currentStatus) => ({ | ||
...currentStatus, | ||
isErrored: true, | ||
})); | ||
return; | ||
} | ||
|
||
if (!isDefined(data?.clientConfig)) { | ||
return; | ||
} | ||
Comment on lines
+60
to
62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Should set isErrored:true here since missing clientConfig is an error state |
||
|
||
setClientConfigApiStatus((currentStatus) => ({ | ||
...currentStatus, | ||
isErrored: false, | ||
})); | ||
|
||
setAuthProviders({ | ||
google: data?.clientConfig.authProviders.google, | ||
microsoft: data?.clientConfig.authProviders.microsoft, | ||
password: data?.clientConfig.authProviders.password, | ||
magicLink: false, | ||
sso: data?.clientConfig.authProviders.sso, | ||
}); | ||
Comment on lines
+70
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Optional chaining is inconsistent - using ?. for clientConfig but not for nested authProviders properties |
||
setIsDebugMode(data?.clientConfig.debugMode); | ||
setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled); | ||
setIsSignInPrefilled(data?.clientConfig.signInPrefilled); | ||
setIsSignUpDisabled(data?.clientConfig.signUpDisabled); | ||
|
||
setBilling(data?.clientConfig.billing); | ||
setSupportChat(data?.clientConfig.support); | ||
|
||
setSentryConfig({ | ||
dsn: data?.clientConfig?.sentry?.dsn, | ||
release: data?.clientConfig?.sentry?.release, | ||
environment: data?.clientConfig?.sentry?.environment, | ||
}); | ||
|
||
setCaptchaProvider({ | ||
provider: data?.clientConfig?.captcha?.provider, | ||
siteKey: data?.clientConfig?.captcha?.siteKey, | ||
}); | ||
|
||
setChromeExtensionId(data?.clientConfig?.chromeExtensionId); | ||
setApiConfig(data?.clientConfig?.api); | ||
}, [ | ||
data, | ||
setAuthProviders, | ||
|
@@ -83,11 +103,12 @@ export const ClientConfigProviderEffect = () => { | |
setBilling, | ||
setSentryConfig, | ||
loading, | ||
setIsClientConfigLoaded, | ||
setClientConfigApiStatus, | ||
setCaptchaProvider, | ||
setChromeExtensionId, | ||
setApiConfig, | ||
setIsAnalyticsEnabled, | ||
error, | ||
]); | ||
|
||
return <></>; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { createState } from 'twenty-ui'; | ||
|
||
type ClientConfigApiStatus = { | ||
isLoaded: boolean; | ||
isErrored: boolean; | ||
}; | ||
|
||
export const clientConfigApiStatusState = createState<ClientConfigApiStatus>({ | ||
key: 'clientConfigApiStatus', | ||
defaultValue: { isLoaded: false, isErrored: false }, | ||
}); |
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,11 +15,16 @@ import { | |
} from 'twenty-ui'; | ||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; | ||
|
||
type GenericErrorFallbackProps = FallbackProps; | ||
type GenericErrorFallbackProps = FallbackProps & { | ||
title?: string; | ||
isInitialFetch?: boolean; | ||
}; | ||
|
||
export const GenericErrorFallback = ({ | ||
error, | ||
resetErrorBoundary, | ||
title = 'Something went wrong', | ||
isInitialFetch = false, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would be a cleaner component API (and more reusable) to just name this as showPageHeader There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This flag is also used to determine the visibility of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's assume that it will be hard refresh in the future! Even if it's broken for a few days it should come soon |
||
}: GenericErrorFallbackProps) => { | ||
const location = useLocation(); | ||
|
||
|
@@ -33,24 +38,29 @@ export const GenericErrorFallback = ({ | |
|
||
return ( | ||
<PageContainer> | ||
<PageHeader /> | ||
{/* no header for initial fetch failure */} | ||
{!isInitialFetch && <PageHeader />} | ||
|
||
<PageBody> | ||
<AnimatedPlaceholderEmptyContainer> | ||
<AnimatedPlaceholder type="errorIndex" /> | ||
<AnimatedPlaceholderEmptyTextContainer> | ||
<AnimatedPlaceholderEmptyTitle> | ||
Server’s on a coffee break | ||
{title} | ||
</AnimatedPlaceholderEmptyTitle> | ||
<AnimatedPlaceholderEmptySubTitle> | ||
{error.message} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: error.message may be undefined - consider adding a fallback message |
||
</AnimatedPlaceholderEmptySubTitle> | ||
</AnimatedPlaceholderEmptyTextContainer> | ||
<Button | ||
Icon={IconRefresh} | ||
title="Reload" | ||
variant={'secondary'} | ||
onClick={() => resetErrorBoundary()} | ||
/> | ||
{/* no refetch button for initial fetch failure, hard refresh is required */} | ||
{!isInitialFetch && ( | ||
<Button | ||
Icon={IconRefresh} | ||
title="Reload" | ||
variant={'secondary'} | ||
onClick={() => resetErrorBoundary()} | ||
/> | ||
)} | ||
</AnimatedPlaceholderEmptyContainer> | ||
</PageBody> | ||
</PageContainer> | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: Moving ObjectMetadataItemsProvider before ClientConfigProvider could cause issues if metadata queries depend on client config values
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@khuddite I think the AI is right here, one example of a bug that was introduced is the always-loading state here:
From a system architecture perspective I think it makes more sense to have the clientConfig stay at the higher-level. It's definitely a tougher problem than it looked initially!
I think what would make the most sense is to introduce a
BaseThemeProvider
at a higher level that would be similar to AppThemeProvider but with a simplified logic just based on system preference. This might be helpful if we start having more logged-out pages...We can then rename
AppThemeProvider
toUserThemeProvider
and focus its role on updating the context only based on the WorkspaceMember.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That would be a great solution to the problem. Thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In addition to that, I will see if we can move
ErrorBoundary
up to the top level, so the initial config load error gets caught byErrorBoundary
(not manually like we do now). If so, we won't even need to introduce any additional states likeisErrored
orerrorMessage
. The position ofErrorBoundary
wrapper in the current setup doesn't quite make sense to me.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issue to move ErrorBoundary is that the Sentry config comes from ClientConfig... Maybe one day we should push it during the build process in env-config.js instead but today it won't work I think 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good callout, thanks! I could've wasted a lot of time just to figure that out 😁