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
@@ -0,0 +1,17 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { subscribeToSignOutFromOtherTabs } from '@/auth/utils/crossTabSignOut';
import { useEffect } from 'react';
export const SignOutOnOtherTabSignOutEffect = () => {
const { clearSession } = useAuth();
useEffect(() => {
const unsubscribe = subscribeToSignOutFromOtherTabs(() => {
clearSession();
});
return unsubscribe;
}, [clearSession]);
return null;
};
@@ -23,6 +23,8 @@ import {
} from '~/generated-metadata/graphql';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { clearSessionLocalStorageKeys } from '@/auth/utils/clearSessionLocalStorageKeys';
import { broadcastSignOutToOtherTabs } from '@/auth/utils/crossTabSignOut';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
@@ -34,7 +36,8 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { useLoadMockedMinimalMetadata } from '@/metadata-store/hooks/useLoadMockedMinimalMetadata';
import { useLoadMockedMetadata } from '@/metadata-store/hooks/useLoadMockedMetadata';
import { preloadMockedMetadata } from '@/metadata-store/utils/preloadMockedMetadata';
import { lastAuthenticatedMethodState } from '@/auth/states/lastAuthenticatedMethodState';
import { loginTokenState } from '@/auth/states/loginTokenState';
import {
@@ -86,7 +89,7 @@ export const useAuth = () => {
const { loadCurrentUser } = useLoadCurrentUser();
const { clearSseClient } = useClearSseClient();
const { loadMockedMinimalMetadata } = useLoadMockedMinimalMetadata();
const { applyMockedMetadata } = useLoadMockedMetadata();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetAtomState(signInUpStepState);
@@ -126,6 +129,9 @@ export const useAuth = () => {
const clearSession = useCallback(async () => {
clearSseClient();
store.set(isAppEffectRedirectEnabledState.atom, false);
const mockedData = await preloadMockedMetadata();
const authProvidersValue = store.get(workspaceAuthProvidersState.atom);
const domainConfigurationValue = store.get(domainConfigurationState.atom);
@@ -137,10 +143,8 @@ export const useAuth = () => {
isCaptchaScriptLoadedState.atom,
);
store.set(isAppEffectRedirectEnabledState.atom, false);
sessionStorage.clear();
localStorage.clear();
clearSessionLocalStorageKeys();
store.set(workspaceAuthProvidersState.atom, authProvidersValue);
store.set(workspacePublicDataState.atom, workspacePublicDataValue);
@@ -161,16 +165,17 @@ export const useAuth = () => {
store.set(loginTokenState.atom, null);
store.set(signInUpStepState.atom, SignInUpStep.Init);
applyMockedMetadata(mockedData);
await client.clearStore();
setLastAuthenticateWorkspaceDomain(null);
await loadMockedMinimalMetadata();
navigate(AppPath.SignInUp);
store.set(isAppEffectRedirectEnabledState.atom, true);
}, [
clearSseClient,
client,
setLastAuthenticateWorkspaceDomain,
loadMockedMinimalMetadata,
applyMockedMetadata,
navigate,
store,
]);
@@ -476,6 +481,7 @@ export const useAuth = () => {
);
const handleSignOut = useCallback(async () => {
broadcastSignOutToOtherTabs();
await clearSession();
if (isCaptchaScriptLoaded) await requestFreshCaptchaToken();
}, [clearSession, isCaptchaScriptLoaded, requestFreshCaptchaToken]);
@@ -0,0 +1,18 @@
import { safeRemoveLocalStorageItems } from '@/auth/utils/safeRemoveLocalStorageItems';
import {
ALL_METADATA_ENTITY_KEYS,
type MetadataEntityKey,
} from '@/metadata-store/states/metadataStoreState';
import { clearSessionLocalStorageKeys } from './clearSessionLocalStorageKeys';
const METADATA_STORE_PREFIX = 'metadataStoreState__';
const getMetadataStoreKeys = (): string[] =>
ALL_METADATA_ENTITY_KEYS.map(
(key: MetadataEntityKey) => `${METADATA_STORE_PREFIX}${key}`,
);
export const clearAllSessionLocalStorageKeys = () => {
clearSessionLocalStorageKeys();
safeRemoveLocalStorageItems(getMetadataStoreKeys());
};
@@ -0,0 +1,13 @@
import { safeRemoveLocalStorageItems } from '@/auth/utils/safeRemoveLocalStorageItems';
const SESSION_KEYS_TO_CLEAR = [
'lastVisitedObjectMetadataItemIdState',
'lastVisitedViewPerObjectMetadataItemState',
'playgroundApiKeyState',
'ai/agentChatDraftsByThreadIdState',
'locale',
];
export const clearSessionLocalStorageKeys = () => {
safeRemoveLocalStorageItems(SESSION_KEYS_TO_CLEAR);
};
@@ -0,0 +1,41 @@
const SIGN_OUT_CHANNEL_NAME = 'twenty-sign-out';
let sharedChannel: BroadcastChannel | null = null;
const getSharedSignOutChannel = (): BroadcastChannel | null => {
if (sharedChannel) {
return sharedChannel;
}
try {
sharedChannel = new BroadcastChannel(SIGN_OUT_CHANNEL_NAME);
} catch {
return null;
}
return sharedChannel;
};
export const broadcastSignOutToOtherTabs = () => {
getSharedSignOutChannel()?.postMessage({ type: 'sign-out' });
};
export const subscribeToSignOutFromOtherTabs = (
callback: () => void,
): (() => void) => {
const channel = getSharedSignOutChannel();
if (!channel) {
return () => {};
}
channel.onmessage = (event: MessageEvent) => {
if (event.data?.type === 'sign-out') {
callback();
}
};
return () => {
channel.onmessage = null;
};
};
@@ -0,0 +1,9 @@
export const safeRemoveLocalStorageItems = (keys: string[]) => {
for (const key of keys) {
try {
localStorage.removeItem(key);
} catch {
// noop
}
}
};