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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22392?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-07-01 14:50:13 +02:00
committed by GitHub
parent 2b1417770e
commit 4d96ec489b
7 changed files with 260 additions and 80 deletions
@@ -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}
>
<I18nProvider i18n={i18n}>
<ApolloDevLogEffect />
<SnackBarComponentInstanceContext.Provider
value={{ instanceId: 'snack-bar-manager' }}
>
<IconsProvider>
<ExceptionHandlerProvider>
<HelmetProvider>
<ClickOutsideListenerContext.Provider
value={{ excludedClickOutsideId: undefined }}
>
<AppRouter />
</ClickOutsideListenerContext.Provider>
</HelmetProvider>
</ExceptionHandlerProvider>
</IconsProvider>
</SnackBarComponentInstanceContext.Provider>
</I18nProvider>
<I18nActivationGate>
<I18nProvider i18n={i18n}>
<ApolloDevLogEffect />
<SnackBarComponentInstanceContext.Provider
value={{ instanceId: 'snack-bar-manager' }}
>
<IconsProvider>
<ExceptionHandlerProvider>
<HelmetProvider>
<ClickOutsideListenerContext.Provider
value={{ excludedClickOutsideId: undefined }}
>
<AppRouter />
</ClickOutsideListenerContext.Provider>
</HelmetProvider>
</ExceptionHandlerProvider>
</IconsProvider>
</SnackBarComponentInstanceContext.Provider>
</I18nProvider>
</I18nActivationGate>
</AppErrorBoundary>
</JotaiProvider>
);
@@ -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 ? <OnboardingPageLoader /> : null;
}
return <>{children}</>;
};
@@ -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 = (
</Route>
<Route element={<BlankLayout />}>
<Route
path={AppPath.SignInUpV2}
element={
<LazyRoute fallback={null}>
<SignInUpV2 />
</LazyRoute>
}
/>
<Route path={AppPath.VerifyV2} element={<VerifyV2 />} />
<Route
path={AppPath.WorkspaceActivationV2}
element={
<LazyRoute fallback={null}>
<WorkspaceActivationV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.CreateProfileV2}
element={
<LazyRoute fallback={null}>
<CreateProfileV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.SyncEmailsV2}
element={
<LazyRoute fallback={null}>
<SyncEmailsV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.InstallAppsV2}
element={
<LazyRoute fallback={null}>
<InstallAppsV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.InviteTeamV2}
element={
<LazyRoute fallback={null}>
<InviteTeamV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.PlanRequiredV2}
element={
<LazyRoute fallback={null}>
<ChooseYourPlanV2 />
</LazyRoute>
}
/>
element={<OnboardingV2TransitionOutlet />}
loader={preloadOnboardingV2Pages}
>
<Route
path={AppPath.SignInUpV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<SignInUpV2 />
</LazyRoute>
}
/>
<Route path={AppPath.VerifyV2} element={<VerifyV2 />} />
<Route
path={AppPath.WorkspaceActivationV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<WorkspaceActivationV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.CreateProfileV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<CreateProfileV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.SyncEmailsV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<SyncEmailsV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.InstallAppsV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<InstallAppsV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.InviteTeamV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<InviteTeamV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.PlanRequiredV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<ChooseYourPlanV2 />
</LazyRoute>
}
/>
</Route>
<Route
path={AppPath.Authorize}
element={
@@ -0,0 +1,12 @@
import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_V2_PATHS = [
AppPath.SignInUpV2,
AppPath.VerifyV2,
AppPath.WorkspaceActivationV2,
AppPath.CreateProfileV2,
AppPath.SyncEmailsV2,
AppPath.InstallAppsV2,
AppPath.InviteTeamV2,
AppPath.PlanRequiredV2,
];
@@ -0,0 +1,19 @@
import { OnboardingPulsingLogo } from '@/onboarding/components/OnboardingPulsingLogo';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
align-items: center;
background: ${themeCssVariables.background.primary};
display: flex;
flex-direction: column;
height: 100dvh;
justify-content: center;
width: 100%;
`;
export const OnboardingPageLoader = () => (
<StyledContainer>
<OnboardingPulsingLogo />
</StyledContainer>
);
@@ -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 (
<StyledTransitionContainer>
<AnimatePresence initial={false}>
<StyledTransitionPage
key={pathname}
initial={{ opacity: 0, y: theme.spacingMultiplicator }}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: -theme.spacingMultiplicator,
pointerEvents: 'none',
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: theme.animation.duration.normal,
ease: 'easeInOut',
}
}
>
{outlet}
</StyledTransitionPage>
</AnimatePresence>
</StyledTransitionContainer>
);
};
@@ -0,0 +1,32 @@
import { type ComponentType } from 'react';
type PreloadableComponent = ComponentType & {
preload: () => Promise<void>;
};
export const lazyWithPreload = (
loader: () => Promise<{ default: ComponentType }>,
): PreloadableComponent => {
let LoadedComponent: ComponentType | null = null;
let loadingPromise: Promise<void> | null = null;
const preload = () => {
loadingPromise ??= loader().then((loadedModule) => {
LoadedComponent = loadedModule.default;
});
return loadingPromise;
};
const PreloadableComponent = () => {
const Component = LoadedComponent;
if (Component === null) {
throw preload();
}
return <Component />;
};
return Object.assign(PreloadableComponent, { preload });
};