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