App version mismatch handling between frontend and backend (#13368)

https://github.com/user-attachments/assets/d153f177-4d70-4ec6-8693-15413e550938
This commit is contained in:
Abdul Rahman
2025-07-24 01:37:02 +05:30
committed by GitHub
parent ed36b19af7
commit 30dd457313
14 changed files with 113 additions and 3 deletions

View File

@ -4,6 +4,7 @@ import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
import { useApolloFactory } from '../useApolloFactory';
enableFetchMocks();
@ -25,7 +26,9 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => (
initialEntries={['/welcome', '/verify', '/opportunities']}
initialIndex={2}
>
{children}
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="test-instance-id">
{children}
</SnackBarComponentInstanceContextProvider>
</MemoryRouter>
</RecoilRoot>
);

View File

@ -1,7 +1,7 @@
import { InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { useMemo, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
@ -13,7 +13,9 @@ import { useUpdateEffect } from '~/hooks/useUpdateEffect';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { appVersionState } from '@/client-config/states/appVersionState';
import { AppPath } from '@/types/AppPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isDefined } from 'twenty-shared/utils';
import { ApolloFactory, Options } from '../services/apollo.factory';
@ -26,6 +28,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const appVersion = useRecoilValue(appVersionState);
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
currentWorkspaceMemberState,
);
@ -35,6 +38,8 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
const setPreviousUrl = useSetRecoilState(previousUrlState);
const location = useLocation();
const { enqueueErrorSnackBar } = useSnackBar();
const apolloClient = useMemo(() => {
apolloRef.current = new ApolloFactory({
uri: `${REACT_APP_SERVER_BASE_URL}/graphql`,
@ -54,6 +59,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
connectToDevTools: process.env.IS_DEBUG_MODE === 'true',
currentWorkspaceMember: currentWorkspaceMember,
currentWorkspace: currentWorkspace,
appVersion,
onTokenPairChange: (tokenPair) => {
setTokenPair(tokenPair);
},
@ -73,6 +79,14 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
navigate(AppPath.SignInUp);
}
},
onAppVersionMismatch: (message) => {
enqueueErrorSnackBar({
message,
options: {
dedupeKey: 'app-version-mismatch',
},
});
},
extraLinks: [],
isDebugMode: process.env.IS_DEBUG_MODE === 'true',
// Override options
@ -87,6 +101,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
setCurrentWorkspaceMember,
setCurrentWorkspace,
setPreviousUrl,
enqueueErrorSnackBar,
]);
useUpdateEffect(() => {
@ -101,5 +116,11 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
}
}, [currentWorkspace]);
useUpdateEffect(() => {
if (isDefined(apolloRef.current)) {
apolloRef.current.updateAppVersion(appVersion);
}
}, [appVersion]);
return apolloClient;
};

View File

@ -64,6 +64,7 @@ const createMockOptions = (): Options<any> => ({
isDebugMode: true,
onError: mockOnError,
onNetworkError: mockOnNetworkError,
appVersion: '1.0.0',
});
const makeRequest = async () => {

View File

@ -23,6 +23,7 @@ import { logDebug } from '~/utils/logDebug';
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { i18n } from '@lingui/core';
import { t } from '@lingui/core/macro';
import {
DefinitionNode,
DirectiveNode,
@ -45,16 +46,19 @@ export interface Options<TCacheShape> extends ApolloClientOptions<TCacheShape> {
onNetworkError?: (err: Error | ServerParseError | ServerError) => void;
onTokenPairChange?: (tokenPair: AuthTokenPair) => void;
onUnauthenticatedError?: () => void;
onAppVersionMismatch?: (message: string) => void;
currentWorkspaceMember: CurrentWorkspaceMember | null;
currentWorkspace: CurrentWorkspace | null;
extraLinks?: ApolloLink[];
isDebugMode?: boolean;
appVersion?: string;
}
export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
private client: ApolloClient<TCacheShape>;
private currentWorkspaceMember: CurrentWorkspaceMember | null = null;
private currentWorkspace: CurrentWorkspace | null = null;
private appVersion?: string;
constructor(opts: Options<TCacheShape>) {
const {
@ -63,15 +67,18 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
onNetworkError,
onTokenPairChange,
onUnauthenticatedError,
onAppVersionMismatch,
currentWorkspaceMember,
currentWorkspace,
extraLinks,
isDebugMode,
appVersion,
...options
} = opts;
this.currentWorkspaceMember = currentWorkspaceMember;
this.currentWorkspace = currentWorkspace;
this.appVersion = appVersion;
const buildApolloLink = (): ApolloLink => {
const uploadLink = createUploadLink({
@ -111,6 +118,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
...(this.currentWorkspace?.metadataVersion && {
'X-Schema-Version': `${this.currentWorkspace.metadataVersion}`,
}),
...(this.appVersion && { 'X-App-Version': this.appVersion }),
},
};
});
@ -158,6 +166,13 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
}
switch (graphQLError?.extensions?.code) {
case 'APP_VERSION_MISMATCH': {
onAppVersionMismatch?.(
(graphQLError.extensions?.userFriendlyMessage as string) ||
t`Your app version is out of date. Please refresh the page.`,
);
return;
}
case 'UNAUTHENTICATED': {
return handleTokenRenewal(operation, forward);
}
@ -289,6 +304,10 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
this.currentWorkspace = workspace;
}
updateAppVersion(appVersion?: string) {
this.appVersion = appVersion;
}
getClient() {
return this.client;
}

View File

@ -1,6 +1,7 @@
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { appVersionState } from '@/client-config/states/appVersionState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
@ -97,6 +98,8 @@ export const ClientConfigProviderEffect = () => {
isImapSmtpCaldavEnabledState,
);
const setAppVersion = useSetRecoilState(appVersionState);
const { data, loading, error, fetchClientConfig } = useClientConfig();
useEffect(() => {
@ -133,7 +136,7 @@ export const ClientConfigProviderEffect = () => {
isErrored: false,
error: undefined,
}));
setAppVersion(data.clientConfig.appVersion);
setAuthProviders({
google: data?.clientConfig.authProviders.google,
microsoft: data?.clientConfig.authProviders.microsoft,
@ -217,6 +220,7 @@ export const ClientConfigProviderEffect = () => {
setIsConfigVariablesInDbEnabled,
setCalendarBookingPageId,
setIsImapSmtpCaldavEnabled,
setAppVersion,
]);
return <></>;

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const appVersionState = createState<string | undefined>({
key: 'appVersion',
defaultValue: undefined,
});

View File

@ -10,6 +10,7 @@ import {
} from '~/generated-metadata/graphql';
export type ClientConfig = {
appVersion?: string;
aiModels: Array<ClientAiModelConfig>;
analyticsEnabled: boolean;
api: ApiConfig;