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. Review in cubic --- .../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 }); +};