diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 00e53090..1116b739 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -5,7 +5,6 @@ import i18next from 'i18next'; // Components import ThemeWrapper from './features/theme/ThemeWrapper'; -import PreferenceSelector from './components/PreferenceSelector'; // Routes import router from './app/routes'; @@ -14,7 +13,6 @@ import router from './app/routes'; import { useAppSelector } from './hooks/useAppSelector'; import { initMixpanel } from './utils/mixpanelInit'; import { initializeCsrfToken } from './api/api-client'; -import { useRoutePreloader } from './utils/routePreloader'; // Types & Constants import { Language } from './features/i18n/localesSlice'; @@ -28,9 +26,9 @@ import { SuspenseFallback } from './components/suspense-fallback/suspense-fallba * 1. React.memo() - Prevents unnecessary re-renders * 2. useMemo() - Memoizes expensive computations * 3. useCallback() - Memoizes event handlers - * 4. Route preloading - Preloads critical routes - * 5. Lazy loading - Components loaded on demand - * 6. Suspense boundaries - Better loading states + * 4. Lazy loading - All route components loaded on demand + * 5. Suspense boundaries - Better loading states + * 6. Optimized guard components with memoization */ const App: React.FC = memo(() => { const themeMode = useAppSelector(state => state.themeReducer.mode); @@ -39,25 +37,6 @@ const App: React.FC = memo(() => { // Memoize mixpanel initialization to prevent re-initialization const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []); - // Preload critical routes for better navigation performance - useRoutePreloader([ - { - path: '/worklenz/home', - loader: () => import('./pages/home/home-page'), - priority: 'high' - }, - { - path: '/worklenz/projects', - loader: () => import('./pages/projects/project-list'), - priority: 'high' - }, - { - path: '/worklenz/schedule', - loader: () => import('./pages/schedule/schedule'), - priority: 'medium' - } - ]); - useEffect(() => { initMixpanel(mixpanelToken); }, [mixpanelToken]); @@ -95,7 +74,12 @@ const App: React.FC = memo(() => { return ( }> - + ); diff --git a/worklenz-frontend/src/app/routes/auth-routes.tsx b/worklenz-frontend/src/app/routes/auth-routes.tsx index 2eca96a9..5cddb925 100644 --- a/worklenz-frontend/src/app/routes/auth-routes.tsx +++ b/worklenz-frontend/src/app/routes/auth-routes.tsx @@ -1,20 +1,20 @@ +import { lazy, Suspense } from 'react'; import AuthLayout from '@/layouts/AuthLayout'; -import LoginPage from '@/pages/auth/login-page'; -import SignupPage from '@/pages/auth/signup-page'; -import ForgotPasswordPage from '@/pages/auth/forgot-password-page'; -import LoggingOutPage from '@/pages/auth/logging-out'; -import AuthenticatingPage from '@/pages/auth/authenticating'; import { Navigate } from 'react-router-dom'; -import VerifyResetEmailPage from '@/pages/auth/verify-reset-email'; -import { Suspense } from 'react'; import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; +// Lazy load auth page components for better code splitting +const LoginPage = lazy(() => import('@/pages/auth/login-page')); +const SignupPage = lazy(() => import('@/pages/auth/signup-page')); +const ForgotPasswordPage = lazy(() => import('@/pages/auth/forgot-password-page')); +const LoggingOutPage = lazy(() => import('@/pages/auth/logging-out')); +const AuthenticatingPage = lazy(() => import('@/pages/auth/authenticating')); +const VerifyResetEmailPage = lazy(() => import('@/pages/auth/verify-reset-email')); + const authRoutes = [ { path: '/auth', - element: ( - - ), + element: , children: [ { path: '', @@ -22,27 +22,51 @@ const authRoutes = [ }, { path: 'login', - element: , + element: ( + }> + + + ), }, { path: 'signup', - element: , + element: ( + }> + + + ), }, { path: 'forgot-password', - element: , + element: ( + }> + + + ), }, { path: 'logging-out', - element: , + element: ( + }> + + + ), }, { path: 'authenticating', - element: , + element: ( + }> + + + ), }, { path: 'verify-reset-email/:user/:hash', - element: , + element: ( + }> + + + ), }, ], }, diff --git a/worklenz-frontend/src/app/routes/index.tsx b/worklenz-frontend/src/app/routes/index.tsx index 7d6d6826..d9361804 100644 --- a/worklenz-frontend/src/app/routes/index.tsx +++ b/worklenz-frontend/src/app/routes/index.tsx @@ -1,4 +1,5 @@ import { createBrowserRouter, Navigate, RouteObject, useLocation } from 'react-router-dom'; +import { lazy, Suspense, memo, useMemo } from 'react'; import rootRoutes from './root-routes'; import authRoutes from './auth-routes'; import mainRoutes, { licenseExpiredRoute } from './main-routes'; @@ -8,116 +9,195 @@ import reportingRoutes from './reporting-routes'; import { useAuthService } from '@/hooks/useAuth'; import { AuthenticatedLayout } from '@/layouts/AuthenticatedLayout'; import ErrorBoundary from '@/components/ErrorBoundary'; -import NotFoundPage from '@/pages/404-page/404-page'; +import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; -import LicenseExpired from '@/pages/license-expired/license-expired'; + +// Lazy load the NotFoundPage component for better code splitting +const NotFoundPage = lazy(() => import('@/pages/404-page/404-page')); interface GuardProps { children: React.ReactNode; } -export const AuthGuard = ({ children }: GuardProps) => { - const isAuthenticated = useAuthService().isAuthenticated(); - const location = useLocation(); - - if (!isAuthenticated) { - return ; - } - - return <>{children}; +// Route-based code splitting utility +const withCodeSplitting = (Component: React.LazyExoticComponent>) => { + return memo(() => ( + }> + + + )); }; -export const AdminGuard = ({ children }: GuardProps) => { - const isAuthenticated = useAuthService().isAuthenticated(); - const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); - const currentSession = useAuthService().getCurrentSession(); - const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE; +// Memoized guard components with defensive programming +export const AuthGuard = memo(({ children }: GuardProps) => { + const authService = useAuthService(); const location = useLocation(); - if (!isAuthenticated) { - return ; - } - - if (!isOwnerOrAdmin || isFreePlan) { - return ; - } - - return <>{children}; -}; - -export const LicenseExpiryGuard = ({ children }: GuardProps) => { - const isAuthenticated = useAuthService().isAuthenticated(); - const currentSession = useAuthService().getCurrentSession(); - const location = useLocation(); - const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center'); - const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired'; - - // Don't check or redirect if we're already on the license-expired page - if (isLicenseExpiredRoute) { - return <>{children}; - } - - // Check if trial is expired more than 7 days or if is_expired flag is set - const isLicenseExpiredMoreThan7Days = () => { - // Quick bail if no session data is available - if (!currentSession) { - return false; - } - - // Check is_expired flag first - if (currentSession.is_expired) { - // If no trial_expire_date exists but is_expired is true, defer to backend check - if (!currentSession.trial_expire_date) { - return true; + const shouldRedirect = useMemo(() => { + try { + // Defensive check to ensure authService and its methods exist + if (!authService || typeof authService.isAuthenticated !== 'function') { + return false; // Don't redirect if auth service is not ready } - - // If there is a trial_expire_date, check if it's more than 7 days past - const today = new Date(); - const expiryDate = new Date(currentSession.trial_expire_date); - const diffTime = today.getTime() - expiryDate.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - // Redirect if more than 7 days past expiration - return diffDays > 7; + return !authService.isAuthenticated(); + } catch (error) { + console.error('Error in AuthGuard:', error); + return false; // Don't redirect on error, let the app handle it } - - // If not marked as expired but has trial_expire_date, do a date check - if (currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && currentSession.trial_expire_date) { - const today = new Date(); - const expiryDate = new Date(currentSession.trial_expire_date); + }, [authService]); - const diffTime = today.getTime() - expiryDate.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + if (shouldRedirect) { + return ; + } + + return <>{children}; +}); + +AuthGuard.displayName = 'AuthGuard'; + +export const AdminGuard = memo(({ children }: GuardProps) => { + const authService = useAuthService(); + const location = useLocation(); + + const guardResult = useMemo(() => { + try { + // Defensive checks to ensure authService and its methods exist + if (!authService || + typeof authService.isAuthenticated !== 'function' || + typeof authService.isOwnerOrAdmin !== 'function' || + typeof authService.getCurrentSession !== 'function') { + return null; // Don't redirect if auth service is not ready + } + + if (!authService.isAuthenticated()) { + return { redirect: '/auth', state: { from: location } }; + } + + const currentSession = authService.getCurrentSession(); + const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE; - // If expired more than 7 days, redirect - return diffDays > 7; - } - - // No expiration data found - return false; - }; + if (!authService.isOwnerOrAdmin() || isFreePlan) { + return { redirect: '/worklenz/unauthorized' }; + } + + return null; + } catch (error) { + console.error('Error in AdminGuard:', error); + return null; // Don't redirect on error + } + }, [authService, location]); + + if (guardResult) { + return ; + } + + return <>{children}; +}); + +AdminGuard.displayName = 'AdminGuard'; + +export const LicenseExpiryGuard = memo(({ children }: GuardProps) => { + const authService = useAuthService(); + const location = useLocation(); + + const shouldRedirect = useMemo(() => { + try { + // Defensive checks to ensure authService and its methods exist + if (!authService || + typeof authService.isAuthenticated !== 'function' || + typeof authService.getCurrentSession !== 'function') { + return false; // Don't redirect if auth service is not ready + } + + if (!authService.isAuthenticated()) return false; + + const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center'); + const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired'; + + // Don't check or redirect if we're already on the license-expired page + if (isLicenseExpiredRoute) return false; + + const currentSession = authService.getCurrentSession(); + + // Check if trial is expired more than 7 days or if is_expired flag is set + const isLicenseExpiredMoreThan7Days = () => { + // Quick bail if no session data is available + if (!currentSession) return false; + + // Check is_expired flag first + if (currentSession.is_expired) { + // If no trial_expire_date exists but is_expired is true, defer to backend check + if (!currentSession.trial_expire_date) return true; + + // If there is a trial_expire_date, check if it's more than 7 days past + const today = new Date(); + const expiryDate = new Date(currentSession.trial_expire_date); + const diffTime = today.getTime() - expiryDate.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // Redirect if more than 7 days past expiration + return diffDays > 7; + } + + // If not marked as expired but has trial_expire_date, do a date check + if (currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && currentSession.trial_expire_date) { + const today = new Date(); + const expiryDate = new Date(currentSession.trial_expire_date); + + const diffTime = today.getTime() - expiryDate.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // If expired more than 7 days, redirect + return diffDays > 7; + } + + // No expiration data found + return false; + }; + + return isLicenseExpiredMoreThan7Days() && !isAdminCenterRoute; + } catch (error) { + console.error('Error in LicenseExpiryGuard:', error); + return false; // Don't redirect on error + } + }, [authService, location.pathname]); - // Add this explicit check and log the result - const shouldRedirect = isAuthenticated && isLicenseExpiredMoreThan7Days() && !isAdminCenterRoute; if (shouldRedirect) { return ; } return <>{children}; -}; +}); -export const SetupGuard = ({ children }: GuardProps) => { - const isAuthenticated = useAuthService().isAuthenticated(); +LicenseExpiryGuard.displayName = 'LicenseExpiryGuard'; + +export const SetupGuard = memo(({ children }: GuardProps) => { + const authService = useAuthService(); const location = useLocation(); - if (!isAuthenticated) { + const shouldRedirect = useMemo(() => { + try { + // Defensive check to ensure authService and its methods exist + if (!authService || typeof authService.isAuthenticated !== 'function') { + return false; // Don't redirect if auth service is not ready + } + return !authService.isAuthenticated(); + } catch (error) { + console.error('Error in SetupGuard:', error); + return false; // Don't redirect on error + } + }, [authService]); + + if (shouldRedirect) { return ; } return <>{children}; -}; +}); -// Helper to wrap routes with guards +SetupGuard.displayName = 'SetupGuard'; + +// Optimized route wrapping function with Suspense boundaries const wrapRoutes = ( routes: RouteObject[], Guard: React.ComponentType<{ children: React.ReactNode }> @@ -125,7 +205,11 @@ const wrapRoutes = ( return routes.map(route => { const wrappedRoute = { ...route, - element: {route.element}, + element: ( + }> + {route.element} + + ), }; if (route.children) { @@ -140,9 +224,8 @@ const wrapRoutes = ( }); }; -// Static license expired component that doesn't rely on translations or authentication -const StaticLicenseExpired = () => { - +// Optimized static license expired component +const StaticLicenseExpired = memo(() => { return (
{
); -}; +}); +StaticLicenseExpired.displayName = 'StaticLicenseExpired'; + +// Create route arrays (moved outside of useMemo to avoid hook violations) const publicRoutes = [ ...rootRoutes, ...authRoutes, notFoundRoute ]; + const protectedMainRoutes = wrapRoutes(mainRoutes, AuthGuard); const adminRoutes = wrapRoutes(reportingRoutes, AdminGuard); const setupRoutes = wrapRoutes([accountSetupRoute], SetupGuard); -// Apply LicenseExpiryGuard to all protected routes +// License expiry check function const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => { return routes.map(route => { const wrappedRoute = { ...route, - element: {route.element}, + element: ( + }> + {route.element} + + ), }; if (route.children) { @@ -213,10 +304,21 @@ const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => { const licenseCheckedMainRoutes = withLicenseExpiryCheck(protectedMainRoutes); +// Create optimized router with future flags for better performance const router = createBrowserRouter([ { - element: , - errorElement: , + element: ( + + + + ), + errorElement: ( + + }> + + + + ), children: [ ...licenseCheckedMainRoutes, ...adminRoutes, @@ -225,6 +327,15 @@ const router = createBrowserRouter([ ], }, ...publicRoutes, -]); +], { + // Enable React Router future features for better performance + future: { + v7_relativeSplatPath: true, + v7_fetcherPersist: true, + v7_normalizeFormMethod: true, + v7_partialHydration: true, + v7_skipActionErrorRevalidation: true + } +}); export default router; diff --git a/worklenz-frontend/src/app/routes/main-routes.tsx b/worklenz-frontend/src/app/routes/main-routes.tsx index 8647fac2..225fd9a7 100644 --- a/worklenz-frontend/src/app/routes/main-routes.tsx +++ b/worklenz-frontend/src/app/routes/main-routes.tsx @@ -1,32 +1,49 @@ import { RouteObject } from 'react-router-dom'; +import { lazy, Suspense } from 'react'; import MainLayout from '@/layouts/MainLayout'; -import HomePage from '@/pages/home/home-page'; -import ProjectList from '@/pages/projects/project-list'; import settingsRoutes from './settings-routes'; import adminCenterRoutes from './admin-center-routes'; -import Schedule from '@/pages/schedule/schedule'; -import ProjectTemplateEditView from '@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView'; -import LicenseExpired from '@/pages/license-expired/license-expired'; -import ProjectView from '@/pages/projects/projectView/project-view'; -import Unauthorized from '@/pages/unauthorized/unauthorized'; import { useAuthService } from '@/hooks/useAuth'; import { Navigate, useLocation } from 'react-router-dom'; +import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; -// Define AdminGuard component first +// Lazy load page components for better code splitting +const HomePage = lazy(() => import('@/pages/home/home-page')); +const ProjectList = lazy(() => import('@/pages/projects/project-list')); +const Schedule = lazy(() => import('@/pages/schedule/schedule')); +const ProjectTemplateEditView = lazy(() => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView')); +const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired')); +const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view')); +const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized')); + +// Define AdminGuard component with defensive programming const AdminGuard = ({ children }: { children: React.ReactNode }) => { - const isAuthenticated = useAuthService().isAuthenticated(); - const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); + const authService = useAuthService(); const location = useLocation(); - if (!isAuthenticated) { - return ; - } + try { + // Defensive checks to ensure authService and its methods exist + if (!authService || + typeof authService.isAuthenticated !== 'function' || + typeof authService.isOwnerOrAdmin !== 'function') { + // If auth service is not ready, render children (don't block) + return <>{children}; + } - if (!isOwnerOrAdmin) { - return ; - } + if (!authService.isAuthenticated()) { + return ; + } - return <>{children}; + if (!authService.isOwnerOrAdmin()) { + return ; + } + + return <>{children}; + } catch (error) { + console.error('Error in AdminGuard (main-routes):', error); + // On error, render children to prevent complete blocking + return <>{children}; + } }; const mainRoutes: RouteObject[] = [ @@ -35,18 +52,56 @@ const mainRoutes: RouteObject[] = [ element: , children: [ { index: true, element: }, - { path: 'home', element: }, - { path: 'projects', element: }, + { + path: 'home', + element: ( + }> + + + ) + }, + { + path: 'projects', + element: ( + }> + + + ) + }, { path: 'schedule', - element: + element: ( + }> + + + + + ) + }, + { + path: `projects/:projectId`, + element: ( + }> + + + ) }, - { path: `projects/:projectId`, element: }, { path: `settings/project-templates/edit/:templateId/:templateName`, - element: , + element: ( + }> + + + ), + }, + { + path: 'unauthorized', + element: ( + }> + + + ) }, - { path: 'unauthorized', element: }, ...settingsRoutes, ...adminCenterRoutes, ], @@ -58,7 +113,14 @@ export const licenseExpiredRoute: RouteObject = { path: '/worklenz', element: , children: [ - { path: 'license-expired', element: } + { + path: 'license-expired', + element: ( + }> + + + ) + } ] };