Skip to content
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

Merged
merged 7 commits into from
Nov 22, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,16 @@ export const AppRouterProviders = () => {
return (
<ApolloProvider>
<ClientConfigProviderEffect />
<ClientConfigProvider>
<ChromeExtensionSidecarEffect />
<ChromeExtensionSidecarProvider>
<UserProviderEffect />
<UserProvider>
<AuthProvider>
<ApolloMetadataClientProvider>
<ObjectMetadataItemsProvider>
<PrefetchDataProvider>
<AppThemeProvider>
<ApolloMetadataClientProvider>
<ObjectMetadataItemsProvider>
<AppThemeProvider>
Copy link
Contributor

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

Copy link
Member

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:

Screenshot 2024-11-21 at 14 45 36

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 to UserThemeProvider and focus its role on updating the context only based on the WorkspaceMember.

Copy link
Contributor Author

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!

Copy link
Contributor Author

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 by ErrorBoundary(not manually like we do now). If so, we won't even need to introduce any additional states like isErrored or errorMessage. The position of ErrorBoundary wrapper in the current setup doesn't quite make sense to me.

Copy link
Member

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 🤔

Copy link
Contributor Author

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 😁

<ClientConfigProvider>
<ChromeExtensionSidecarEffect />
<ChromeExtensionSidecarProvider>
<UserProviderEffect />
<UserProvider>
<AuthProvider>
<PrefetchDataProvider>
<SnackBarProvider>
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager>
Expand All @@ -50,15 +50,15 @@ export const AppRouterProviders = () => {
</DialogManager>
</DialogManagerScope>
</SnackBarProvider>
</AppThemeProvider>
</PrefetchDataProvider>
<PageChangeEffect />
</ObjectMetadataItemsProvider>
</ApolloMetadataClientProvider>
</AuthProvider>
</UserProvider>
</ChromeExtensionSidecarProvider>
</ClientConfigProvider>
</PrefetchDataProvider>
<PageChangeEffect />
</AuthProvider>
</UserProvider>
</ChromeExtensionSidecarProvider>
</ClientConfigProvider>
</AppThemeProvider>
</ObjectMetadataItemsProvider>
</ApolloMetadataClientProvider>
</ApolloProvider>
);
};
8 changes: 4 additions & 4 deletions packages/twenty-front/src/modules/auth/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { workspacesState } from '@/auth/states/workspaces';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
import { supportChatState } from '@/client-config/states/supportChatState';
Expand Down Expand Up @@ -229,8 +229,8 @@ export const useAuth = () => {
const captchaProvider = snapshot
.getLoadable(captchaProviderState)
.getValue();
const isClientConfigLoaded = snapshot
.getLoadable(isClientConfigLoadedState)
const clientConfigApiStatus = snapshot
.getLoadable(clientConfigApiStatusState)
.getValue();
const isCurrentUserLoaded = snapshot
.getLoadable(isCurrentUserLoadedState)
Expand All @@ -244,7 +244,7 @@ export const useAuth = () => {
set(supportChatState, supportChat);
set(isDebugModeState, isDebugMode);
set(captchaProviderState, captchaProvider);
set(isClientConfigLoadedState, isClientConfigLoaded);
set(clientConfigApiStatusState, clientConfigApiStatus);
set(isCurrentUserLoadedState, isCurrentUserLoaded);
return undefined;
});
Expand Down
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>
Copy link
Member

Choose a reason for hiding this comment

The 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. ClientConfigError?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, that's a good idea!

Copy link
Member

Choose a reason for hiding this comment

The 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')}
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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={() => {}}
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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;
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would return isLoaded ? children : null; work then? If you decide not to wrap in component makes sense to do it on both side

};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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,
Expand All @@ -83,11 +103,12 @@ export const ClientConfigProviderEffect = () => {
setBilling,
setSentryConfig,
loading,
setIsClientConfigLoaded,
setClientConfigApiStatus,
setCaptchaProvider,
setChromeExtensionId,
setApiConfig,
setIsAnalyticsEnabled,
error,
]);

return <></>;
Expand Down
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
Expand Up @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flag is also used to determine the visibility of refetch button. I am not sure if showPageHeader is still a good name in that case.

Copy link
Member

Choose a reason for hiding this comment

The 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();

Expand All @@ -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}
Copy link
Contributor

Choose a reason for hiding this comment

The 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>
Expand Down
2 changes: 1 addition & 1 deletion packages/twenty-ui/src/display/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ export * from './icon/types/IconComponent';
export * from './info/components/Info';
export * from './status/components/Status';
export * from './tag/components/Tag';
export * from './text/components/SeparatorLineText';
export * from './text/components/HorizontalSeparator';
export * from './text/components/SeparatorLineText';
export * from './tooltip/AppTooltip';
export * from './tooltip/OverflowingTextWithTooltip';
export * from './typography/components/H1Title';
Expand Down
Loading