fix: metadata store lifecycle during sign-in, sign-out, and locale change (#18901)

## Summary

Fixes metadata lifecycle bugs during sign-in, sign-out, and locale
change:

- **Cross-tab sign-out**: Broadcasts sign-out via `BroadcastChannel` so
other tabs clear their session gracefully instead of hitting stale-token
errors
- **SSE teardown on sign-out**: Handles `UNAUTHENTICATED`/`FORBIDDEN`
errors in the SSE event stream effect instead of throwing unhandled
errors
- **Locale switch resilience**: `invalidateAndReload` now invalidates
collection hashes instead of clearing the store to empty, so components
never see 0 metadata items during the reload transition
- **Sign-in background mock**: Uses non-throwing
`objectMetadataItemFamilySelector` instead of hooks that throw on
missing metadata
- **View name placeholders**: Guards against `undefined` `viewName`
during metadata transitions (the minimal metadata query doesn't include
`name`)
- **Session cleanup**: Selective `localStorage` clearing
(`clearSessionLocalStorageKeys`) preserves metadata keys;
`clearAllSessionLocalStorageKeys` for full clears
- **Metadata reload API**: New `useMetadataStoreActions` hook as the
high-level API for metadata lifecycle operations (`applyMockedMetadata`,
`invalidateAndReload`, `loadMockedMetadataAtomic`)

## Test plan

- [ ] Sign out on Tab A → Tab A shows sign-in page with no console
errors
- [ ] Tab B (logged in) receives cross-tab broadcast and redirects to
sign-in
- [ ] No "Forbidden resource" SSE errors in console during sign-out
- [ ] Change language in Settings > Experience → no crash, metadata
refreshes in background
- [ ] Sign back in after sign-out → metadata loads correctly, app is
functional
- [ ] Re-sign-in after locale change → correct locale is preserved
This commit is contained in:
Charles Bochet
2026-03-24 15:57:53 +01:00
committed by GitHub
parent 2317a701bd
commit 611947e031
36 changed files with 394 additions and 160 deletions
@@ -2,8 +2,9 @@ import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { useLoadMinimalMetadata } from '@/metadata-store/hooks/useLoadMinimalMetadata';
import { useLoadMockedMinimalMetadata } from '@/metadata-store/hooks/useLoadMockedMinimalMetadata';
import { useLoadMockedMetadata } from '@/metadata-store/hooks/useLoadMockedMetadata';
import { useLoadStaleMetadataEntities } from '@/metadata-store/hooks/useLoadStaleMetadataEntities';
import { metadataLoadedVersionState } from '@/metadata-store/states/metadataLoadedVersionState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useEffect, useState } from 'react';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
@@ -25,10 +26,14 @@ export const MinimalMetadataLoadEffect = () => {
const hasAccessTokenPair = useHasAccessTokenPair();
const isCurrentUserLoaded = useAtomStateValue(isCurrentUserLoadedState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const [loadedState, setLoadedState] = useState<LoadedState>('none');
const metadataLoadedVersion = useAtomStateValue(metadataLoadedVersionState);
const [lastMetadataLoadData, setLastMetadataLoadData] = useState<{
state: LoadedState;
version: number;
}>({ state: 'none', version: -1 });
const { loadMinimalMetadata } = useLoadMinimalMetadata();
const { loadMockedMinimalMetadata } = useLoadMockedMinimalMetadata();
const { loadMockedMetadataAtomic } = useLoadMockedMetadata();
const { loadStaleMetadataEntities } = useLoadStaleMetadataEntities();
const isActiveWorkspace = isWorkspaceActiveOrSuspended(currentWorkspace);
@@ -39,15 +44,25 @@ export const MinimalMetadataLoadEffect = () => {
);
useEffect(() => {
if (!isCurrentUserLoaded || loadedState === desiredLoadState) {
if (!isCurrentUserLoaded) {
return;
}
setLoadedState(desiredLoadState);
const versionChanged =
metadataLoadedVersion !== lastMetadataLoadData.version;
if (!versionChanged && lastMetadataLoadData.state === desiredLoadState) {
return;
}
setLastMetadataLoadData({
state: desiredLoadState,
version: metadataLoadedVersion,
});
const performLoad = async () => {
if (desiredLoadState === 'mocked') {
await loadMockedMinimalMetadata();
await loadMockedMetadataAtomic();
return;
}
@@ -64,9 +79,10 @@ export const MinimalMetadataLoadEffect = () => {
hasAccessTokenPair,
isActiveWorkspace,
desiredLoadState,
loadedState,
lastMetadataLoadData,
metadataLoadedVersion,
loadMinimalMetadata,
loadMockedMinimalMetadata,
loadMockedMetadataAtomic,
loadStaleMetadataEntities,
]);