From 4d96ec489b8b0573dc2fc8c9e81af3d3a48dc274 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Wed, 1 Jul 2026 14:50:13 +0200
Subject: [PATCH] Add smooth page transitions to onboarding v2 (#22392)
## Before
https://github.com/user-attachments/assets/d2fcd5ce-7e34-4f07-9a52-cac8acdc37cd
## After
https://github.com/user-attachments/assets/5c245949-cff9-41b2-802d-3deeb562efa6
On a full-page load of a v2 onboarding URL (the post-signup
workspace-subdomain redirect), Lingui's `I18nProvider` renders `null`
until the locale chunk async-activates, so the app is blank for ~2s
before the verify step appears. Steps also hard-cut and flashed a loader
between each other.
- Show a pulsing-logo loader until the locale activates (a gate above
`I18nProvider`), scoped to onboarding v2 paths so every other page is
unchanged.
- Cross-fade between steps and preload their chunks on entry, so
navigating never flashes the loader.
Frontend-only; i18n loading itself is untouched.
---
.../src/modules/app/components/App.tsx | 39 ++---
.../app/components/I18nActivationGate.tsx | 40 +++++
.../modules/app/hooks/useCreateAppRouter.tsx | 143 ++++++++++--------
.../auth/constants/OnboardingV2Paths.ts | 12 ++
.../components/OnboardingPageLoader.tsx | 19 +++
.../OnboardingV2TransitionOutlet.tsx | 55 +++++++
.../src/utils/lazyWithPreload.tsx | 32 ++++
7 files changed, 260 insertions(+), 80 deletions(-)
create mode 100644 packages/twenty-front/src/modules/app/components/I18nActivationGate.tsx
create mode 100644 packages/twenty-front/src/modules/auth/constants/OnboardingV2Paths.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/components/OnboardingPageLoader.tsx
create mode 100644 packages/twenty-front/src/modules/onboarding/components/OnboardingV2TransitionOutlet.tsx
create mode 100644 packages/twenty-front/src/utils/lazyWithPreload.tsx
diff --git a/packages/twenty-front/src/modules/app/components/App.tsx b/packages/twenty-front/src/modules/app/components/App.tsx
index 9bd2012bf2..bd42bb81b5 100644
--- a/packages/twenty-front/src/modules/app/components/App.tsx
+++ b/packages/twenty-front/src/modules/app/components/App.tsx
@@ -1,4 +1,5 @@
import { AppRouter } from '@/app/components/AppRouter';
+import { I18nActivationGate } from '@/app/components/I18nActivationGate';
import { ApolloDevLogEffect } from '@/debug/components/ApolloDevLogEffect';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { AppRootErrorFallback } from '@/error-handler/components/AppRootErrorFallback';
@@ -22,24 +23,26 @@ export const App = () => {
resetOnLocationChange={false}
FallbackComponent={AppRootErrorFallback}
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/packages/twenty-front/src/modules/app/components/I18nActivationGate.tsx b/packages/twenty-front/src/modules/app/components/I18nActivationGate.tsx
new file mode 100644
index 0000000000..c5faf40429
--- /dev/null
+++ b/packages/twenty-front/src/modules/app/components/I18nActivationGate.tsx
@@ -0,0 +1,40 @@
+import { ONBOARDING_V2_PATHS } from '@/auth/constants/OnboardingV2Paths';
+import { OnboardingPageLoader } from '@/onboarding/components/OnboardingPageLoader';
+import { i18n } from '@lingui/core';
+import { isNonEmptyString } from '@sniptt/guards';
+import { type ReactNode, useEffect, useState } from 'react';
+import { matchPath } from 'react-router-dom';
+import { isDefined } from 'twenty-shared/utils';
+
+type I18nActivationGateProps = {
+ children: ReactNode;
+};
+
+export const I18nActivationGate = ({ children }: I18nActivationGateProps) => {
+ const [isLocaleActivated, setIsLocaleActivated] = useState(() =>
+ isNonEmptyString(i18n.locale),
+ );
+
+ useEffect(() => {
+ const handleLocaleChange = () => {
+ if (isNonEmptyString(i18n.locale)) {
+ setIsLocaleActivated(true);
+ }
+ };
+
+ const unsubscribe = i18n.on('change', handleLocaleChange);
+ handleLocaleChange();
+
+ return unsubscribe;
+ }, []);
+
+ if (!isLocaleActivated) {
+ const isOnboardingLocation = ONBOARDING_V2_PATHS.some((onboardingPath) =>
+ isDefined(matchPath(onboardingPath, window.location.pathname)),
+ );
+
+ return isOnboardingLocation ? : null;
+ }
+
+ return <>{children}>;
+};
diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
index b18e0c58bd..41a434ae36 100644
--- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
+++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
@@ -5,7 +5,10 @@ import { VerifyLoginTokenEffect } from '@/auth/components/VerifyLoginTokenEffect
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import indexAppPath from '@/navigation/utils/indexAppPath';
+import { OnboardingPageLoader } from '@/onboarding/components/OnboardingPageLoader';
+import { OnboardingV2TransitionOutlet } from '@/onboarding/components/OnboardingV2TransitionOutlet';
import { VerifyV2 } from '~/pages/onboarding/VerifyV2';
+import { lazyWithPreload } from '~/utils/lazyWithPreload';
import { RecordIndexSkeletonLoader } from '@/object-record/record-index/components/RecordIndexSkeletonLoader';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
@@ -63,7 +66,7 @@ const WorkspaceActivation = lazy(() =>
})),
);
-const WorkspaceActivationV2 = lazy(() =>
+const WorkspaceActivationV2 = lazyWithPreload(() =>
import('~/pages/onboarding/WorkspaceActivationV2').then((module) => ({
default: module.WorkspaceActivationV2,
})),
@@ -75,7 +78,7 @@ const CreateProfile = lazy(() =>
})),
);
-const CreateProfileV2 = lazy(() =>
+const CreateProfileV2 = lazyWithPreload(() =>
import('~/pages/onboarding/CreateProfileV2').then((module) => ({
default: module.CreateProfileV2,
})),
@@ -87,13 +90,13 @@ const SyncEmails = lazy(() =>
})),
);
-const SyncEmailsV2 = lazy(() =>
+const SyncEmailsV2 = lazyWithPreload(() =>
import('~/pages/onboarding/SyncEmailsV2').then((module) => ({
default: module.SyncEmailsV2,
})),
);
-const InstallAppsV2 = lazy(() =>
+const InstallAppsV2 = lazyWithPreload(() =>
import('~/pages/onboarding/InstallAppsV2').then((module) => ({
default: module.InstallAppsV2,
})),
@@ -105,7 +108,7 @@ const InviteTeam = lazy(() =>
})),
);
-const InviteTeamV2 = lazy(() =>
+const InviteTeamV2 = lazyWithPreload(() =>
import('~/pages/onboarding/InviteTeamV2').then((module) => ({
default: module.InviteTeamV2,
})),
@@ -117,7 +120,7 @@ const ChooseYourPlan = lazy(() =>
})),
);
-const ChooseYourPlanV2 = lazy(() =>
+const ChooseYourPlanV2 = lazyWithPreload(() =>
import('~/pages/onboarding/ChooseYourPlanV2').then((module) => ({
default: module.ChooseYourPlanV2,
})),
@@ -153,6 +156,17 @@ const NotFound = lazy(() =>
})),
);
+const preloadOnboardingV2Pages = () => {
+ void WorkspaceActivationV2.preload();
+ void CreateProfileV2.preload();
+ void SyncEmailsV2.preload();
+ void InstallAppsV2.preload();
+ void InviteTeamV2.preload();
+ void ChooseYourPlanV2.preload();
+
+ return null;
+};
+
export const useCreateAppRouter = (
isFunctionSettingsEnabled?: boolean,
isAdminPageEnabled?: boolean,
@@ -312,62 +326,67 @@ export const useCreateAppRouter = (
}>
-
-
- }
- />
- } />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
+ element={}
+ loader={preloadOnboardingV2Pages}
+ >
+ }>
+
+
+ }
+ />
+ } />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+
(
+
+
+
+);
diff --git a/packages/twenty-front/src/modules/onboarding/components/OnboardingV2TransitionOutlet.tsx b/packages/twenty-front/src/modules/onboarding/components/OnboardingV2TransitionOutlet.tsx
new file mode 100644
index 0000000000..1fc36f265f
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/components/OnboardingV2TransitionOutlet.tsx
@@ -0,0 +1,55 @@
+import { styled } from '@linaria/react';
+import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
+import { useContext } from 'react';
+import { useLocation, useOutlet } from 'react-router-dom';
+import { ThemeContext } from 'twenty-ui/theme-constants';
+
+const StyledTransitionContainer = styled.div`
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ position: relative;
+`;
+
+const StyledTransitionPage = styled(motion.div)`
+ display: flex;
+ inset: 0;
+ min-height: 0;
+ min-width: 0;
+ position: absolute;
+`;
+
+export const OnboardingV2TransitionOutlet = () => {
+ const { pathname } = useLocation();
+ const outlet = useOutlet();
+ const shouldReduceMotion = useReducedMotion();
+ const { theme } = useContext(ThemeContext);
+
+ return (
+
+
+
+ {outlet}
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/utils/lazyWithPreload.tsx b/packages/twenty-front/src/utils/lazyWithPreload.tsx
new file mode 100644
index 0000000000..1b699b9cbe
--- /dev/null
+++ b/packages/twenty-front/src/utils/lazyWithPreload.tsx
@@ -0,0 +1,32 @@
+import { type ComponentType } from 'react';
+
+type PreloadableComponent = ComponentType & {
+ preload: () => Promise;
+};
+
+export const lazyWithPreload = (
+ loader: () => Promise<{ default: ComponentType }>,
+): PreloadableComponent => {
+ let LoadedComponent: ComponentType | null = null;
+ let loadingPromise: Promise | null = null;
+
+ const preload = () => {
+ loadingPromise ??= loader().then((loadedModule) => {
+ LoadedComponent = loadedModule.default;
+ });
+
+ return loadingPromise;
+ };
+
+ const PreloadableComponent = () => {
+ const Component = LoadedComponent;
+
+ if (Component === null) {
+ throw preload();
+ }
+
+ return ;
+ };
+
+ return Object.assign(PreloadableComponent, { preload });
+};