Fix "PageChangeEffect does not run when changing view on the same object" (#12196)

Fixes https://github.com/twentyhq/core-team-issues/issues/950

This issue was due to the memoization inside `useIsMatchingLocation`,
which was rerendered only if the pathname changed but not the search
params.

After discussion with @lucasbordeau, we decided to remove the hook
`useIsMatchingLocation` and to create an equivalent util function which
takes the location as an argument.

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Raphaël Bosi
2025-05-22 12:06:07 +02:00
committed by GitHub
parent 6466f3fb45
commit ffdedf7af3
16 changed files with 232 additions and 195 deletions

View File

@ -1,74 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useIsMatchingLocation } from '../useIsMatchingLocation';
import { MemoryRouter } from 'react-router-dom';
import { AppBasePath } from '@/types/AppBasePath';
const Wrapper =
(initialIndex = 0) =>
({ children }: { children: React.ReactNode }) => (
<MemoryRouter
initialEntries={['/example', '/other', `${AppBasePath.Settings}/example`]}
initialIndex={initialIndex}
>
{children}
</MemoryRouter>
);
describe('useIsMatchingLocation', () => {
it('returns true when paths match with no basePath', () => {
const { result } = renderHook(() => useIsMatchingLocation(), {
wrapper: Wrapper(),
});
expect(result.current.isMatchingLocation('/example')).toBe(true);
});
it('returns false when paths do not match with no basePath', () => {
const { result } = renderHook(() => useIsMatchingLocation(), {
wrapper: Wrapper(),
});
expect(result.current.isMatchingLocation('/non-match')).toBe(false);
});
it('returns true when paths match with basePath', () => {
const { result } = renderHook(() => useIsMatchingLocation(), {
wrapper: Wrapper(2),
});
expect(
result.current.isMatchingLocation('example', AppBasePath.Settings),
).toBe(true);
});
it('returns false when paths do not match with basePath', () => {
const { result } = renderHook(() => useIsMatchingLocation(), {
wrapper: Wrapper(),
});
expect(
result.current.isMatchingLocation('non-match', AppBasePath.Settings),
).toBe(false);
});
it('handles trailing slashes in basePath correctly', () => {
const { result } = renderHook(() => useIsMatchingLocation(), {
wrapper: Wrapper(2),
});
expect(
result.current.isMatchingLocation(
'example',
(AppBasePath.Settings + '/') as AppBasePath,
),
).toBe(true);
});
it('handles without basePath correctly', () => {
const { result } = renderHook(() => useIsMatchingLocation(), {
wrapper: Wrapper(),
});
expect(result.current.isMatchingLocation('example')).toBe(true);
});
});

View File

@ -8,9 +8,9 @@ import { useRecoilValue } from 'recoil';
import { OnboardingStatus } from '~/generated/graphql'; import { OnboardingStatus } from '~/generated/graphql';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation'; import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
import { UNTESTED_APP_PATHS } from '~/testing/constants/UntestedAppPaths'; import { UNTESTED_APP_PATHS } from '~/testing/constants/UntestedAppPaths';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
jest.mock('@/onboarding/hooks/useOnboardingStatus'); jest.mock('@/onboarding/hooks/useOnboardingStatus');
const setupMockOnboardingStatus = ( const setupMockOnboardingStatus = (
@ -28,13 +28,13 @@ const setupMockIsWorkspaceActivationStatusEqualsTo = (
.mockReturnValueOnce(isWorkspaceSuspended); .mockReturnValueOnce(isWorkspaceSuspended);
}; };
jest.mock('~/hooks/useIsMatchingLocation'); jest.mock('~/utils/isMatchingLocation');
const mockUseIsMatchingLocation = jest.mocked(useIsMatchingLocation); const mockIsMatchingLocation = jest.mocked(isMatchingLocation);
const setupMockIsMatchingLocation = (pathname: string) => { const setupMockIsMatchingLocation = (pathname: string) => {
mockUseIsMatchingLocation.mockReturnValueOnce({ mockIsMatchingLocation.mockImplementation(
isMatchingLocation: (path: string) => path === pathname, (_location, path) => path === pathname,
}); );
}; };
jest.mock('@/auth/hooks/useIsLogged'); jest.mock('@/auth/hooks/useIsLogged');

View File

@ -1,34 +0,0 @@
import { matchPath, useLocation } from 'react-router-dom';
import { AppBasePath } from '@/types/AppBasePath';
import { isNonEmptyString } from '@sniptt/guards';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
const addTrailingSlash = (path: string) =>
path.endsWith('/') ? path : path + '/';
const getConstructedPath = (path: string, basePath?: AppBasePath) => {
if (!isNonEmptyString(basePath)) return path;
return addTrailingSlash(basePath) + path;
};
export const useIsMatchingLocation = () => {
const location = useLocation();
const isMatchingLocation = useCallback(
(path: string, basePath?: AppBasePath) => {
const match = matchPath(
getConstructedPath(path, basePath),
location.pathname,
);
return isDefined(match);
},
[location.pathname],
);
return {
isMatchingLocation,
};
};

View File

@ -5,24 +5,24 @@ import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo'; import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { OnboardingStatus } from '~/generated/graphql'; import { OnboardingStatus } from '~/generated/graphql';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const usePageChangeEffectNavigateLocation = () => { export const usePageChangeEffectNavigateLocation = () => {
const { isMatchingLocation } = useIsMatchingLocation();
const isLoggedIn = useIsLogged(); const isLoggedIn = useIsLogged();
const onboardingStatus = useOnboardingStatus(); const onboardingStatus = useOnboardingStatus();
const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo( const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.SUSPENDED, WorkspaceActivationStatus.SUSPENDED,
); );
const { defaultHomePagePath } = useDefaultHomePagePath(); const { defaultHomePagePath } = useDefaultHomePagePath();
const location = useLocation();
const someMatchingLocationOf = (appPaths: AppPath[]): boolean => const someMatchingLocationOf = (appPaths: AppPath[]): boolean =>
appPaths.some((appPath) => isMatchingLocation(appPath)); appPaths.some((appPath) => isMatchingLocation(location, appPath));
const onGoingUserCreationPaths = [ const onGoingUserCreationPaths = [
AppPath.Invite, AppPath.Invite,
AppPath.SignInUp, AppPath.SignInUp,
@ -61,7 +61,10 @@ export const usePageChangeEffectNavigateLocation = () => {
return AppPath.PlanRequired; return AppPath.PlanRequired;
} }
if (isWorkspaceSuspended && !isMatchingLocation(AppPath.SettingsCatchAll)) { if (
isWorkspaceSuspended &&
!isMatchingLocation(location, AppPath.SettingsCatchAll)
) {
return `${AppPath.SettingsCatchAll.replace('/*', '')}/${ return `${AppPath.SettingsCatchAll.replace('/*', '')}/${
SettingsPath.Billing SettingsPath.Billing
}`; }`;
@ -79,21 +82,21 @@ export const usePageChangeEffectNavigateLocation = () => {
if ( if (
onboardingStatus === OnboardingStatus.PROFILE_CREATION && onboardingStatus === OnboardingStatus.PROFILE_CREATION &&
!isMatchingLocation(AppPath.CreateProfile) !isMatchingLocation(location, AppPath.CreateProfile)
) { ) {
return AppPath.CreateProfile; return AppPath.CreateProfile;
} }
if ( if (
onboardingStatus === OnboardingStatus.SYNC_EMAIL && onboardingStatus === OnboardingStatus.SYNC_EMAIL &&
!isMatchingLocation(AppPath.SyncEmails) !isMatchingLocation(location, AppPath.SyncEmails)
) { ) {
return AppPath.SyncEmails; return AppPath.SyncEmails;
} }
if ( if (
onboardingStatus === OnboardingStatus.INVITE_TEAM && onboardingStatus === OnboardingStatus.INVITE_TEAM &&
!isMatchingLocation(AppPath.InviteTeam) !isMatchingLocation(location, AppPath.InviteTeam)
) { ) {
return AppPath.InviteTeam; return AppPath.InviteTeam;
} }
@ -101,18 +104,18 @@ export const usePageChangeEffectNavigateLocation = () => {
if ( if (
onboardingStatus === OnboardingStatus.COMPLETED && onboardingStatus === OnboardingStatus.COMPLETED &&
someMatchingLocationOf([...onboardingPaths, ...onGoingUserCreationPaths]) && someMatchingLocationOf([...onboardingPaths, ...onGoingUserCreationPaths]) &&
!isMatchingLocation(AppPath.ResetPassword) && !isMatchingLocation(location, AppPath.ResetPassword) &&
isLoggedIn isLoggedIn
) { ) {
return defaultHomePagePath; return defaultHomePagePath;
} }
if (isMatchingLocation(AppPath.Index) && isLoggedIn) { if (isMatchingLocation(location, AppPath.Index) && isLoggedIn) {
return defaultHomePagePath; return defaultHomePagePath;
} }
if ( if (
isMatchingLocation(AppPath.RecordIndexPage) && isMatchingLocation(location, AppPath.RecordIndexPage) &&
!isDefined(objectMetadataItem) !isDefined(objectMetadataItem)
) { ) {
return AppPath.NotFound; return AppPath.NotFound;

View File

@ -11,8 +11,8 @@ import { tokenPairState } from '@/auth/states/tokenPairState';
import { workspacesState } from '@/auth/states/workspaces'; import { workspacesState } from '@/auth/states/workspaces';
import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { useUpdateEffect } from '~/hooks/useUpdateEffect'; import { useUpdateEffect } from '~/hooks/useUpdateEffect';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
@ -25,7 +25,6 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
const [isDebugMode] = useRecoilState(isDebugModeState); const [isDebugMode] = useRecoilState(isDebugModeState);
const navigate = useNavigate(); const navigate = useNavigate();
const { isMatchingLocation } = useIsMatchingLocation();
const setTokenPair = useSetRecoilState(tokenPairState); const setTokenPair = useSetRecoilState(tokenPairState);
const [currentWorkspace, setCurrentWorkspace] = useRecoilState( const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState, currentWorkspaceState,
@ -74,10 +73,10 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
setCurrentUserWorkspace(null); setCurrentUserWorkspace(null);
setWorkspaces([]); setWorkspaces([]);
if ( if (
!isMatchingLocation(AppPath.Verify) && !isMatchingLocation(location, AppPath.Verify) &&
!isMatchingLocation(AppPath.SignInUp) && !isMatchingLocation(location, AppPath.SignInUp) &&
!isMatchingLocation(AppPath.Invite) && !isMatchingLocation(location, AppPath.Invite) &&
!isMatchingLocation(AppPath.ResetPassword) !isMatchingLocation(location, AppPath.ResetPassword)
) { ) {
setPreviousUrl(`${location.pathname}${location.search}`); setPreviousUrl(`${location.pathname}${location.search}`);
navigate(AppPath.SignInUp); navigate(AppPath.SignInUp);

View File

@ -36,15 +36,14 @@ import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { AnalyticsType } from '~/generated/graphql'; import { AnalyticsType } from '~/generated/graphql';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation'; import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState'; import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { getPageTitleFromPath } from '~/utils/title-utils'; import { getPageTitleFromPath } from '~/utils/title-utils';
// TODO: break down into smaller functions and / or hooks // TODO: break down into smaller functions and / or hooks
// - moved usePageChangeEffectNavigateLocation into dedicated hook // - moved usePageChangeEffectNavigateLocation into dedicated hook
export const PageChangeEffect = () => { export const PageChangeEffect = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { isMatchingLocation } = useIsMatchingLocation();
const [previousLocation, setPreviousLocation] = useState(''); const [previousLocation, setPreviousLocation] = useState('');
@ -126,7 +125,6 @@ export const PageChangeEffect = () => {
} }
} }
}, [ }, [
isMatchingLocation,
previousLocation, previousLocation,
resetTableSelections, resetTableSelections,
unfocusRecordTableRow, unfocusRecordTableRow,
@ -139,28 +137,28 @@ export const PageChangeEffect = () => {
useEffect(() => { useEffect(() => {
switch (true) { switch (true) {
case isMatchingLocation(AppPath.RecordIndexPage): { case isMatchingLocation(location, AppPath.RecordIndexPage): {
setHotkeyScope(RecordIndexHotkeyScope.RecordIndex, { setHotkeyScope(RecordIndexHotkeyScope.RecordIndex, {
goto: true, goto: true,
keyboardShortcutMenu: true, keyboardShortcutMenu: true,
}); });
break; break;
} }
case isMatchingLocation(AppPath.RecordShowPage): { case isMatchingLocation(location, AppPath.RecordShowPage): {
setHotkeyScope(PageHotkeyScope.CompanyShowPage, { setHotkeyScope(PageHotkeyScope.CompanyShowPage, {
goto: true, goto: true,
keyboardShortcutMenu: true, keyboardShortcutMenu: true,
}); });
break; break;
} }
case isMatchingLocation(AppPath.OpportunitiesPage): { case isMatchingLocation(location, AppPath.OpportunitiesPage): {
setHotkeyScope(PageHotkeyScope.OpportunitiesPage, { setHotkeyScope(PageHotkeyScope.OpportunitiesPage, {
goto: true, goto: true,
keyboardShortcutMenu: true, keyboardShortcutMenu: true,
}); });
break; break;
} }
case isMatchingLocation(AppPath.TasksPage): { case isMatchingLocation(location, AppPath.TasksPage): {
setHotkeyScope(PageHotkeyScope.TaskPage, { setHotkeyScope(PageHotkeyScope.TaskPage, {
goto: true, goto: true,
keyboardShortcutMenu: true, keyboardShortcutMenu: true,
@ -168,42 +166,50 @@ export const PageChangeEffect = () => {
break; break;
} }
case isMatchingLocation(AppPath.SignInUp): { case isMatchingLocation(location, AppPath.SignInUp): {
setHotkeyScope(PageHotkeyScope.SignInUp); setHotkeyScope(PageHotkeyScope.SignInUp);
break; break;
} }
case isMatchingLocation(AppPath.Invite): { case isMatchingLocation(location, AppPath.Invite): {
setHotkeyScope(PageHotkeyScope.SignInUp); setHotkeyScope(PageHotkeyScope.SignInUp);
break; break;
} }
case isMatchingLocation(AppPath.CreateProfile): { case isMatchingLocation(location, AppPath.CreateProfile): {
setHotkeyScope(PageHotkeyScope.CreateProfile); setHotkeyScope(PageHotkeyScope.CreateProfile);
break; break;
} }
case isMatchingLocation(AppPath.CreateWorkspace): { case isMatchingLocation(location, AppPath.CreateWorkspace): {
setHotkeyScope(PageHotkeyScope.CreateWorkspace); setHotkeyScope(PageHotkeyScope.CreateWorkspace);
break; break;
} }
case isMatchingLocation(AppPath.SyncEmails): { case isMatchingLocation(location, AppPath.SyncEmails): {
setHotkeyScope(PageHotkeyScope.SyncEmail); setHotkeyScope(PageHotkeyScope.SyncEmail);
break; break;
} }
case isMatchingLocation(AppPath.InviteTeam): { case isMatchingLocation(location, AppPath.InviteTeam): {
setHotkeyScope(PageHotkeyScope.InviteTeam); setHotkeyScope(PageHotkeyScope.InviteTeam);
break; break;
} }
case isMatchingLocation(AppPath.PlanRequired): { case isMatchingLocation(location, AppPath.PlanRequired): {
setHotkeyScope(PageHotkeyScope.PlanRequired); setHotkeyScope(PageHotkeyScope.PlanRequired);
break; break;
} }
case isMatchingLocation(SettingsPath.ProfilePage, AppBasePath.Settings): { case isMatchingLocation(
location,
SettingsPath.ProfilePage,
AppBasePath.Settings,
): {
setHotkeyScope(PageHotkeyScope.ProfilePage, { setHotkeyScope(PageHotkeyScope.ProfilePage, {
goto: true, goto: true,
keyboardShortcutMenu: true, keyboardShortcutMenu: true,
}); });
break; break;
} }
case isMatchingLocation(SettingsPath.Domain, AppBasePath.Settings): { case isMatchingLocation(
location,
SettingsPath.Domain,
AppBasePath.Settings,
): {
setHotkeyScope(PageHotkeyScope.Settings, { setHotkeyScope(PageHotkeyScope.Settings, {
goto: false, goto: false,
keyboardShortcutMenu: true, keyboardShortcutMenu: true,
@ -211,6 +217,7 @@ export const PageChangeEffect = () => {
break; break;
} }
case isMatchingLocation( case isMatchingLocation(
location,
SettingsPath.WorkspaceMembersPage, SettingsPath.WorkspaceMembersPage,
AppBasePath.Settings, AppBasePath.Settings,
): { ): {
@ -221,7 +228,7 @@ export const PageChangeEffect = () => {
break; break;
} }
} }
}, [isMatchingLocation, setHotkeyScope]); }, [location, setHotkeyScope]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {

View File

@ -1,6 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { SubmitHandler, UseFormReturn } from 'react-hook-form'; import { SubmitHandler, UseFormReturn } from 'react-hook-form';
import { useParams, useSearchParams } from 'react-router-dom'; import { useLocation, useParams, useSearchParams } from 'react-router-dom';
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { signInUpModeState } from '@/auth/states/signInUpModeState'; import { signInUpModeState } from '@/auth/states/signInUpModeState';
@ -15,7 +15,7 @@ import { AppPath } from '@/types/AppPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
export const useSignInUp = (form: UseFormReturn<Form>) => { export const useSignInUp = (form: UseFormReturn<Form>) => {
@ -24,14 +24,16 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState); const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
const { isMatchingLocation } = useIsMatchingLocation(); const location = useLocation();
const workspaceInviteHash = useParams().workspaceInviteHash; const workspaceInviteHash = useParams().workspaceInviteHash;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const workspacePersonalInviteToken = const workspacePersonalInviteToken =
searchParams.get('inviteToken') ?? undefined; searchParams.get('inviteToken') ?? undefined;
const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite)); const [isInviteMode] = useState(() =>
isMatchingLocation(location, AppPath.Invite),
);
const { const {
signInWithCredentials, signInWithCredentials,

View File

@ -3,7 +3,8 @@ import { useRecoilValue } from 'recoil';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback'; import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useLocation } from 'react-router-dom';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
children, children,
@ -12,13 +13,13 @@ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
clientConfigApiStatusState, clientConfigApiStatusState,
); );
const { isMatchingLocation } = useIsMatchingLocation(); const location = useLocation();
// TODO: Implement a better loading strategy // TODO: Implement a better loading strategy
if ( if (
!isLoaded && !isLoaded &&
!isMatchingLocation(AppPath.Verify) && !isMatchingLocation(location, AppPath.Verify) &&
!isMatchingLocation(AppPath.VerifyEmail) !isMatchingLocation(location, AppPath.VerifyEmail)
) )
return null; return null;

View File

@ -5,10 +5,10 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { prefetchIndexViewIdFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchIndexViewIdFromObjectMetadataItemFamilySelector'; import { prefetchIndexViewIdFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchIndexViewIdFromObjectMetadataItemFamilySelector';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { useParams, useSearchParams } from 'react-router-dom'; import { useLocation, useParams, useSearchParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { isMatchingLocation } from '~/utils/isMatchingLocation';
const getViewId = ( const getViewId = (
viewIdFromQueryParams: string | null, viewIdFromQueryParams: string | null,
@ -31,9 +31,12 @@ const getViewId = (
}; };
export const MainContextStoreProvider = () => { export const MainContextStoreProvider = () => {
const { isMatchingLocation } = useIsMatchingLocation(); const location = useLocation();
const isRecordIndexPage = isMatchingLocation(AppPath.RecordIndexPage); const isRecordIndexPage = isMatchingLocation(
const isRecordShowPage = isMatchingLocation(AppPath.RecordShowPage); location,
AppPath.RecordIndexPage,
);
const isRecordShowPage = isMatchingLocation(location, AppPath.RecordShowPage);
const isSettingsPage = useIsSettingsPage(); const isSettingsPage = useIsSettingsPage();
const objectNamePlural = useParams().objectNamePlural ?? ''; const objectNamePlural = useParams().objectNamePlural ?? '';

View File

@ -1,19 +1,23 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useLocation } from 'react-router-dom';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const useShowFullscreen = () => { export const useShowFullscreen = () => {
const { isMatchingLocation } = useIsMatchingLocation(); const location = useLocation();
return useMemo(() => { return useMemo(() => {
if ( if (
isMatchingLocation('settings/' + SettingsPath.RestPlayground + '/*') || isMatchingLocation(
isMatchingLocation('settings/' + SettingsPath.GraphQLPlayground) location,
'settings/' + SettingsPath.RestPlayground + '/*',
) ||
isMatchingLocation(location, 'settings/' + SettingsPath.GraphQLPlayground)
) { ) {
return true; return true;
} }
return false; return false;
}, [isMatchingLocation]); }, [location]);
}; };

View File

@ -1,17 +1,25 @@
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import * as reactRouterDom from 'react-router-dom';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { isMatchingLocation } from '~/utils/isMatchingLocation';
jest.mock('~/hooks/useIsMatchingLocation'); jest.mock('react-router-dom', () => ({
const mockUseIsMatchingLocation = jest.mocked(useIsMatchingLocation); useLocation: jest.fn(),
}));
const mockUseLocation = reactRouterDom.useLocation as jest.Mock;
jest.mock('~/utils/isMatchingLocation');
const mockIsMatchingLocation = jest.mocked(isMatchingLocation);
const setupMockIsMatchingLocation = (pathname: string) => { const setupMockIsMatchingLocation = (pathname: string) => {
mockUseIsMatchingLocation.mockReturnValueOnce({ mockUseLocation.mockReturnValue({ pathname });
isMatchingLocation: (path: string) => path === pathname, mockIsMatchingLocation.mockImplementation(
}); (_location, path) => path === pathname,
);
}; };
const getResult = () => const getResult = () =>

View File

@ -1,28 +1,29 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useLocation } from 'react-router-dom';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const useShowAuthModal = () => { export const useShowAuthModal = () => {
const { isMatchingLocation } = useIsMatchingLocation(); const location = useLocation();
return useMemo(() => { return useMemo(() => {
if ( if (
isMatchingLocation(AppPath.Invite) || isMatchingLocation(location, AppPath.Invite) ||
isMatchingLocation(AppPath.InviteTeam) || isMatchingLocation(location, AppPath.InviteTeam) ||
isMatchingLocation(AppPath.CreateProfile) || isMatchingLocation(location, AppPath.CreateProfile) ||
isMatchingLocation(AppPath.SyncEmails) || isMatchingLocation(location, AppPath.SyncEmails) ||
isMatchingLocation(AppPath.ResetPassword) || isMatchingLocation(location, AppPath.ResetPassword) ||
isMatchingLocation(AppPath.VerifyEmail) || isMatchingLocation(location, AppPath.VerifyEmail) ||
isMatchingLocation(AppPath.Verify) || isMatchingLocation(location, AppPath.Verify) ||
isMatchingLocation(AppPath.SignInUp) || isMatchingLocation(location, AppPath.SignInUp) ||
isMatchingLocation(AppPath.CreateWorkspace) || isMatchingLocation(location, AppPath.CreateWorkspace) ||
isMatchingLocation(AppPath.PlanRequired) || isMatchingLocation(location, AppPath.PlanRequired) ||
isMatchingLocation(AppPath.PlanRequiredSuccess) isMatchingLocation(location, AppPath.PlanRequiredSuccess)
) { ) {
return true; return true;
} }
return false; return false;
}, [isMatchingLocation]); }, [location]);
}; };

View File

@ -5,19 +5,20 @@ import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingStat
import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState'; import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { UserContext } from '@/users/contexts/UserContext'; import { UserContext } from '@/users/contexts/UserContext';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useLocation } from 'react-router-dom';
import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader'; import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const UserProvider = ({ children }: React.PropsWithChildren) => { export const UserProvider = ({ children }: React.PropsWithChildren) => {
const isCurrentUserLoaded = useRecoilValue(isCurrentUserLoadedState); const isCurrentUserLoaded = useRecoilValue(isCurrentUserLoadedState);
const { isMatchingLocation } = useIsMatchingLocation(); const location = useLocation();
const dateTimeFormat = useRecoilValue(dateTimeFormatState); const dateTimeFormat = useRecoilValue(dateTimeFormatState);
return !isCurrentUserLoaded && return !isCurrentUserLoaded &&
!isMatchingLocation(AppPath.Verify) && !isMatchingLocation(location, AppPath.Verify) &&
!isMatchingLocation(AppPath.VerifyEmail) && !isMatchingLocation(location, AppPath.VerifyEmail) &&
!isMatchingLocation(AppPath.CreateWorkspace) ? ( !isMatchingLocation(location, AppPath.CreateWorkspace) ? (
<UserOrMetadataLoader /> <UserOrMetadataLoader />
) : ( ) : (
<UserContext.Provider <UserContext.Provider

View File

@ -19,16 +19,17 @@ import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDa
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat'; import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember'; import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { useLocation } from 'react-router-dom';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { WorkspaceMember } from '~/generated-metadata/graphql'; import { WorkspaceMember } from '~/generated-metadata/graphql';
import { useGetCurrentUserQuery } from '~/generated/graphql'; import { useGetCurrentUserQuery } from '~/generated/graphql';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const UserProviderEffect = () => { export const UserProviderEffect = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const { isMatchingLocation } = useIsMatchingLocation(); const location = useLocation();
const [isCurrentUserLoaded, setIsCurrentUserLoaded] = useRecoilState( const [isCurrentUserLoaded, setIsCurrentUserLoaded] = useRecoilState(
isCurrentUserLoadedState, isCurrentUserLoadedState,
@ -53,8 +54,8 @@ export const UserProviderEffect = () => {
const { loading: queryLoading, data: queryData } = useGetCurrentUserQuery({ const { loading: queryLoading, data: queryData } = useGetCurrentUserQuery({
skip: skip:
isCurrentUserLoaded || isCurrentUserLoaded ||
isMatchingLocation(AppPath.Verify) || isMatchingLocation(location, AppPath.Verify) ||
isMatchingLocation(AppPath.VerifyEmail), isMatchingLocation(location, AppPath.VerifyEmail),
}); });
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,89 @@
import { AppBasePath } from '@/types/AppBasePath';
import { isMatchingLocation } from '../isMatchingLocation';
describe('isMatchingLocation', () => {
it('returns true when paths match with no basePath', () => {
const location = {
pathname: '/example',
state: null,
key: 'test-key',
search: '',
hash: '',
};
const result = isMatchingLocation(location, '/example');
expect(result).toBe(true);
});
it('returns false when paths do not match with no basePath', () => {
const location = {
pathname: '/example',
state: null,
key: 'test-key',
search: '',
hash: '',
};
const result = isMatchingLocation(location, '/non-match');
expect(result).toBe(false);
});
it('returns true when paths match with basePath', () => {
const location = {
pathname: `${AppBasePath.Settings}/example`,
state: null,
key: 'test-key',
search: '',
hash: '',
};
const result = isMatchingLocation(
location,
'example',
AppBasePath.Settings,
);
expect(result).toBe(true);
});
it('returns false when paths do not match with basePath', () => {
const location = {
pathname: `${AppBasePath.Settings}/example`,
state: null,
key: 'test-key',
search: '',
hash: '',
};
const result = isMatchingLocation(
location,
'non-match',
AppBasePath.Settings,
);
expect(result).toBe(false);
});
it('handles trailing slashes in basePath correctly', () => {
const location = {
pathname: `${AppBasePath.Settings}/example`,
state: null,
key: 'test-key',
search: '',
hash: '',
};
const result = isMatchingLocation(
location,
'example',
(AppBasePath.Settings + '/') as AppBasePath,
);
expect(result).toBe(true);
});
it('handles paths without basePath correctly', () => {
const location = {
pathname: '/example',
state: null,
key: 'test-key',
search: '',
hash: '',
};
const result = isMatchingLocation(location, '/example');
expect(result).toBe(true);
});
});

View File

@ -0,0 +1,26 @@
import { Location, matchPath } from 'react-router-dom';
import { AppBasePath } from '@/types/AppBasePath';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
const addTrailingSlash = (path: string) =>
path.endsWith('/') ? path : path + '/';
const getConstructedPath = (path: string, basePath?: AppBasePath) => {
if (!isNonEmptyString(basePath)) return path;
return addTrailingSlash(basePath) + path;
};
export const isMatchingLocation = (
location: Location,
path: string,
basePath?: AppBasePath,
): boolean => {
const match = matchPath(
getConstructedPath(path, basePath),
location.pathname,
);
return isDefined(match);
};