Files
twenty/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts
T
Charles Bochet 9ac503e3af fix(front): defer default home redirect when object metadata is not loaded (#20330)
## Summary

Fixes the merge-queue E2E failures introduced after #20308. After login,
users were being silently redirected to `/settings/profile` instead of
their workspace home, which broke every dependent E2E test that re-uses
the post-login URL (`workflow-creation.spec.ts`,
`authentication/signup_invite_email.spec.ts`, etc.).

## Root cause

`useDefaultHomePagePath` falls back to `/settings/profile` when
`readableNonSystemObjectMetadataItems` is empty. That list is empty in
two cases:

1. The user genuinely has no readable objects → `/settings/profile` is
the intended fallback.
2. Object metadata simply hasn't been loaded yet (transient post-login
window).

Before #20308 the frontend always loaded mocked metadata for
authenticated users, so case (2) never happened. After #20308 mocked
metadata is gone, and during the post-verify window
(`handleLoadWorkspaceAfterAuthentication` finishes,
`setIsAppEffectRedirectEnabled(true)` re-enables redirects,
`PageChangeEffect` fires) the metadata store is still empty. The hook
then returns `/settings/profile`. Because that path is not in
`ONBOARDING_PATHS` / `ONGOING_USER_CREATION_PATHS`,
`usePageChangeEffectNavigateLocation` doesn't fire a corrective redirect
once metadata finally loads — the user is stranded.

`login.setup.ts` captures `process.env.LINK = page.url()` after verify,
so subsequent tests `goto(LINK)` end up in Settings looking for app
navigation that isn't there → click timeouts.

## Fix

Distinguish the two empty cases by reading
`metadataStoreState('objectMetadataItems').status`. If it isn't
`'up-to-date'` we defer to `AppPath.Index` instead of
`/settings/profile`. The memo recomputes when the status flips, and the
user is then routed to their actual home page.

A regression test is added in `useDefaultHomePagePath.test.ts` for the
not-loaded-yet case.

## Test plan

- [x] Unit: `npx jest
src/modules/navigation/hooks/__tests__/useDefaultHomePagePath.test.ts`
(5/5 pass, including new regression case)
- [ ] CI: Playwright E2E (`workflow-creation.spec.ts`,
`authentication/signup_invite_email.spec.ts`) pass on this branch
- [ ] Manual: log in to a fresh local instance and confirm landing page
is the workspace home, not `/settings/profile`
2026-05-07 09:51:55 +02:00

139 lines
4.9 KiB
TypeScript

import { currentUserState } from '@/auth/states/currentUserState';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { lastVisitedObjectMetadataItemIdState } from '@/navigation/states/lastVisitedObjectMetadataItemIdState';
import { type ObjectPathInfo } from '@/navigation/types/ObjectPathInfo';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import isEmpty from 'lodash.isempty';
import { useCallback, useMemo } from 'react';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getAppPath, getSettingsPath, isDefined } from 'twenty-shared/utils';
import { useStore } from 'jotai';
export const useDefaultHomePagePath = () => {
const store = useStore();
const currentUser = useAtomStateValue(currentUserState);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const metadataStore = useAtomFamilyStateValue(
metadataStoreState,
'objectMetadataItems',
);
const areObjectMetadataItemsLoaded = metadataStore.status === 'up-to-date';
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const readableNonSystemObjectMetadataItems = useMemo(
() =>
filterReadableActiveObjectMetadataItems(
activeObjectMetadataItems,
objectPermissionsByObjectMetadataId,
)
.filter((item) => !item.isSystem)
.sort((a, b) => a.nameSingular.localeCompare(b.nameSingular)),
[activeObjectMetadataItems, objectPermissionsByObjectMetadataId],
);
const getActiveObjectMetadataItemMatchingId = useCallback(
(objectMetadataId: string) => {
return readableNonSystemObjectMetadataItems.find(
(item) => item.id === objectMetadataId,
);
},
[readableNonSystemObjectMetadataItems],
);
const views = useAtomStateValue(viewsSelector);
const getFirstView = useCallback(
(objectMetadataItemId: string | undefined | null) => {
return views.find(
(view) => view.objectMetadataId === objectMetadataItemId,
);
},
[views],
);
const firstObjectPathInfo = useMemo<ObjectPathInfo | null>(() => {
const [firstObjectMetadataItem] = readableNonSystemObjectMetadataItems;
if (!isDefined(firstObjectMetadataItem)) {
return null;
}
const view = getFirstView(firstObjectMetadataItem?.id);
return { objectMetadataItem: firstObjectMetadataItem, view };
}, [getFirstView, readableNonSystemObjectMetadataItems]);
const getDefaultObjectPathInfo = useCallback(() => {
const lastVisitedObjectMetadataItemId = store.get(
lastVisitedObjectMetadataItemIdState.atom,
);
const lastVisitedObjectMetadataItem = isDefined(
lastVisitedObjectMetadataItemId,
)
? getActiveObjectMetadataItemMatchingId(lastVisitedObjectMetadataItemId)
: undefined;
if (isDefined(lastVisitedObjectMetadataItem)) {
return {
view: getFirstView(lastVisitedObjectMetadataItemId),
objectMetadataItem: lastVisitedObjectMetadataItem,
};
}
return firstObjectPathInfo;
}, [
firstObjectPathInfo,
getActiveObjectMetadataItemMatchingId,
getFirstView,
store,
]);
const defaultHomePagePath = useMemo(() => {
if (!isDefined(currentUser)) {
return AppPath.SignInUp;
}
if (isEmpty(readableNonSystemObjectMetadataItems)) {
// Object metadata may legitimately be empty for a user with no readable
// objects, in which case /settings/profile is the intended fallback.
// It can also be transiently empty during the post-login window before
// workspace metadata has finished loading. Defer to AppPath.Index in
// that case so the user isn't stranded on /settings/profile once
// metadata becomes available.
if (!areObjectMetadataItemsLoaded) {
return AppPath.Index;
}
return getSettingsPath(SettingsPath.ProfilePage);
}
const defaultObjectPathInfo = getDefaultObjectPathInfo();
if (!isDefined(defaultObjectPathInfo)) {
return AppPath.NotFound;
}
const namePlural = defaultObjectPathInfo.objectMetadataItem?.namePlural;
const viewId = defaultObjectPathInfo.view?.id;
return getAppPath(
AppPath.RecordIndexPage,
{ objectNamePlural: namePlural },
viewId ? { viewId } : undefined,
);
}, [
currentUser,
getDefaultObjectPathInfo,
readableNonSystemObjectMetadataItems,
areObjectMetadataItemsLoaded,
]);
return { defaultHomePagePath };
};