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:
+17
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user