From f869ce87b1bfe3e8556cfa3b163b48defd4fc96f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 13 Jun 2026 18:38:45 +0200 Subject: [PATCH] [Experiment] perf(front): cache-first currentUser bootstrap (#21532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Experiment — not for merge as-is A perf experiment for discussion. Opening as a draft to gather feedback and let CI run. ## Problem On a warm (returning) load, the app gate ([`MinimalMetadataGater`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx)) blocks first paint until **both** object/view metadata **and** `currentUser` are ready. The metadata store is already cache-first: it persists each entity (including `status: 'up-to-date'`) to `localStorage` with `getOnInit`, opens from cache, and revalidates in the background via collection hashes. 👏 `currentUser` (and `currentWorkspace` / `currentWorkspaceMember` / `currentUserWorkspace`) is **not** — it lives in an in-memory atom, so every load fires a blocking `GetCurrentUser` round-trip before the gate opens. That round-trip is the one remaining network hop on the warm-load critical path; everything else the first screen needs is already in `localStorage`. ## Approach Generalize the pattern the metadata store already proves out, to the user bootstrap — **without adding any new `useEffect`**: - Persist the four bootstrap atoms (`currentUser`, `currentWorkspace`, `currentWorkspaceMember`, `currentUserWorkspace`) via the existing `createAtomState({ useLocalStorage, localStorageOptions: { getOnInit: true } })`. - The gate opens from cache on its own: the existing `IsMinimalMetadataReadyEffect` already derives readiness from the `currentUser` atom alongside metadata status, so persisting the atoms is enough — no new effect. - Keep firing `GetCurrentUser` (now `network-only`, no longer skipped when a user is present) so it **revalidates in the background** and the existing write-through effect updates the atoms with the fresh result. - Clear the cached identity on sign-out by adding the four keys to `clearSessionLocalStorageKeys` (already invoked by `clearSession`, which then hard-reloads). Net effect: warm loads no longer wait on `GetCurrentUser`; the shell paints from cache and corrects within one round-trip. Cold loads (no cache) are unchanged. ## Risks to validate - **Permission staleness** — `currentUserWorkspace` carries `objectsPermissions` / `permissionFlags`. Cache-first means a brief stale-permission window before revalidation. Not a security boundary (the server authorizes every request), but it can momentarily show a menu item the user no longer has; worst case it 401s and corrects on the next paint. - **Feature-flag / workspace staleness** — `currentWorkspace.featureFlags` may be one round-trip stale on warm load. - **`X-Schema-Version` header** — sourced from `currentWorkspace.metadataVersion`; caching it actually makes the header *consistent* with the already-cached metadata rather than absent, but worth confirming against the server's mismatch handling. - **Test isolation** — these atoms now persist; tests relying on the default `null` could see cross-test leakage if `localStorage` isn't reset. The directly-affected suites pass locally (`useAuth`, `useDefaultHomePagePath`, `useSetNextOnboardingStatus`); CI's full run is the real check. ## Validation - [ ] Full CI (types/lint/unit) green - [ ] Manual: throttle network, hard-reload a logged-in workspace, confirm the shell paints before `GetCurrentUser` resolves and that fresh data writes through - [ ] Sign out → sign in as a different user on the same browser; confirm no stale identity flashes --- .../src/modules/auth/states/currentUserState.ts | 2 ++ .../src/modules/auth/states/currentUserWorkspaceState.ts | 2 ++ .../src/modules/auth/states/currentWorkspaceMemberState.ts | 2 ++ .../src/modules/auth/states/currentWorkspaceState.ts | 2 ++ .../src/modules/auth/utils/clearSessionLocalStorageKeys.ts | 4 ++++ .../effect-components/MinimalMetadataLoadEffect.tsx | 6 +++++- .../effect-components/UserMetadataProviderInitialEffect.tsx | 5 ++--- 7 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/twenty-front/src/modules/auth/states/currentUserState.ts b/packages/twenty-front/src/modules/auth/states/currentUserState.ts index 9bca9860fd..c684882e81 100644 --- a/packages/twenty-front/src/modules/auth/states/currentUserState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentUserState.ts @@ -18,4 +18,6 @@ export type CurrentUser = Pick< export const currentUserState = createAtomState({ key: 'currentUserState', defaultValue: null, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, }); diff --git a/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts index c15d50e1ce..0e735c1c4f 100644 --- a/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts @@ -13,4 +13,6 @@ export const currentUserWorkspaceState = createAtomState({ key: 'currentUserWorkspaceState', defaultValue: null, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, }); diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts index e844d1615c..be5bcbda30 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts @@ -10,4 +10,6 @@ export const currentWorkspaceMemberState = createAtomState({ key: 'currentWorkspaceMemberState', defaultValue: null, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, }); diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 3d318e6463..e6382bc043 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -54,4 +54,6 @@ export type CurrentWorkspace = Pick< export const currentWorkspaceState = createAtomState({ key: 'currentWorkspaceState', defaultValue: null, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, }); diff --git a/packages/twenty-front/src/modules/auth/utils/clearSessionLocalStorageKeys.ts b/packages/twenty-front/src/modules/auth/utils/clearSessionLocalStorageKeys.ts index df316a554e..8b5116080d 100644 --- a/packages/twenty-front/src/modules/auth/utils/clearSessionLocalStorageKeys.ts +++ b/packages/twenty-front/src/modules/auth/utils/clearSessionLocalStorageKeys.ts @@ -5,6 +5,10 @@ const SESSION_KEYS_TO_CLEAR = [ 'lastVisitedViewPerObjectMetadataItemState', 'ai/agentChatDraftsByThreadIdState', 'locale', + 'currentUserState', + 'currentWorkspaceState', + 'currentWorkspaceMemberState', + 'currentUserWorkspaceState', ]; export const clearSessionLocalStorageKeys = () => { diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/MinimalMetadataLoadEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/MinimalMetadataLoadEffect.tsx index 5e683446a2..2f11cd147c 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/MinimalMetadataLoadEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/MinimalMetadataLoadEffect.tsx @@ -1,4 +1,5 @@ import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; +import { currentUserState } from '@/auth/states/currentUserState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState'; import { useLoadMinimalMetadata } from '@/metadata-store/hooks/useLoadMinimalMetadata'; @@ -6,11 +7,13 @@ import { useLoadStaleMetadataEntities } from '@/metadata-store/hooks/useLoadStal import { metadataLoadedVersionState } from '@/metadata-store/states/metadataLoadedVersionState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useEffect, useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace'; export const MinimalMetadataLoadEffect = () => { const hasAccessTokenPair = useHasAccessTokenPair(); const isCurrentUserLoaded = useAtomStateValue(isCurrentUserLoadedState); + const currentUser = useAtomStateValue(currentUserState); const currentWorkspace = useAtomStateValue(currentWorkspaceState); const metadataLoadedVersion = useAtomStateValue(metadataLoadedVersionState); const [lastLoadedVersion, setLastLoadedVersion] = useState(-1); @@ -22,7 +25,7 @@ export const MinimalMetadataLoadEffect = () => { const shouldLoadRealMetadata = hasAccessTokenPair && isActiveWorkspace; useEffect(() => { - if (!isCurrentUserLoaded) { + if (!isCurrentUserLoaded && !isDefined(currentUser)) { return; } @@ -47,6 +50,7 @@ export const MinimalMetadataLoadEffect = () => { performLoad(); }, [ isCurrentUserLoaded, + currentUser, shouldLoadRealMetadata, lastLoadedVersion, metadataLoadedVersion, diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx index 1ff6fddf9d..853dafe72c 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx @@ -9,7 +9,6 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState'; import { useInitializeFormatPreferences } from '@/localization/hooks/useInitializeFormatPreferences'; import { getDateFnsLocale } from '@/ui/field/display/utils/getDateFnsLocale'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember'; import { enUS } from 'date-fns/locale'; @@ -28,7 +27,6 @@ import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; export const UserMetadataProviderInitialEffect = () => { const hasAccessTokenPair = useHasAccessTokenPair(); - const currentUser = useAtomStateValue(currentUserState); const store = useStore(); const [isInitialized, setIsInitialized] = useState(false); @@ -65,12 +63,13 @@ export const UserMetadataProviderInitialEffect = () => { [store], ); - const shouldSkipUserQuery = !hasAccessTokenPair || isDefined(currentUser); + const shouldSkipUserQuery = !hasAccessTokenPair; const { data: userQueryData, loading: userQueryLoading } = useQuery( GetCurrentUserDocument, { skip: shouldSkipUserQuery, + fetchPolicy: 'network-only', }, );