feat(performance): optimize resource loading and initialization

- Added resource hints in index.html for improved loading performance, including preconnect and dns-prefetch links.
- Implemented preload for critical JSON resources to enhance initial load times.
- Optimized Google Analytics and HubSpot script loading to defer execution and reduce blocking during initial render.
- Refactored app initialization in App.tsx to defer non-critical operations, improving perceived performance.
- Introduced lazy loading for chart components and TinyMCE editor to minimize initial bundle size and enhance user experience.
- Enhanced Vite configuration for optimized chunking strategy and improved caching with shorter hash lengths.
This commit is contained in:
chamikaJ
2025-07-07 12:41:23 +05:30
parent 26b47aac53
commit aa1fb1c6f5
7 changed files with 583 additions and 177 deletions

View File

@@ -5,43 +5,74 @@
<link rel="icon" href="./favicon.ico" /> <link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2b2b2b" /> <meta name="theme-color" content="#2b2b2b" />
<!-- Resource hints for better loading performance -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://js.hs-scripts.com" />
<!-- Preload critical resources -->
<link rel="preload" href="/locales/en/common.json" as="fetch" type="application/json" crossorigin />
<link rel="preload" href="/locales/en/auth/login.json" as="fetch" type="application/json" crossorigin />
<link rel="preload" href="/locales/en/navbar.json" as="fetch" type="application/json" crossorigin />
<!-- Optimized font loading with font-display: swap -->
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet" rel="stylesheet"
media="print"
onload="this.media='all'"
/> />
<noscript>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
</noscript>
<title>Worklenz</title> <title>Worklenz</title>
<!-- Environment configuration --> <!-- Environment configuration -->
<script src="/env-config.js"></script> <script src="/env-config.js"></script>
<!-- Google Analytics -->
<!-- Optimized Google Analytics with reduced blocking -->
<script> <script>
// Function to initialize Google Analytics // Function to initialize Google Analytics asynchronously
function initGoogleAnalytics() { function initGoogleAnalytics() {
// Load the Google Analytics script // Use requestIdleCallback to defer analytics loading
const script = document.createElement('script'); const loadAnalytics = () => {
script.async = true; // Determine which tracking ID to use based on the environment
const isProduction =
window.location.hostname === 'worklenz.com' ||
window.location.hostname === 'app.worklenz.com';
// Determine which tracking ID to use based on the environment const trackingId = isProduction ? 'G-XXXXXXXXXX' : 'G-3LM2HGWEXG'; // Open source tracking ID
const isProduction =
window.location.hostname === 'worklenz.com' ||
window.location.hostname === 'app.worklenz.com';
const trackingId = isProduction ? 'G-XXXXXXXXXX' : 'G-3LM2HGWEXG'; // Open source tracking ID // Load the Google Analytics script
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`;
document.head.appendChild(script);
script.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`; // Initialize Google Analytics
document.head.appendChild(script); window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', trackingId);
};
// Initialize Google Analytics // Use requestIdleCallback if available, otherwise setTimeout
window.dataLayer = window.dataLayer || []; if ('requestIdleCallback' in window) {
function gtag() { requestIdleCallback(loadAnalytics, { timeout: 2000 });
dataLayer.push(arguments); } else {
setTimeout(loadAnalytics, 1000);
} }
gtag('js', new Date());
gtag('config', trackingId);
} }
// Initialize analytics // Initialize analytics after a delay to not block initial render
initGoogleAnalytics(); initGoogleAnalytics();
// Function to show privacy notice // Function to show privacy notice
@@ -98,14 +129,24 @@
<div id="root"></div> <div id="root"></div>
<script type="module" src="./src/index.tsx"></script> <script type="module" src="./src/index.tsx"></script>
<script type="text/javascript"> <script type="text/javascript">
// Load HubSpot script asynchronously and only for production
if (window.location.hostname === 'app.worklenz.com') { if (window.location.hostname === 'app.worklenz.com') {
var hs = document.createElement('script'); // Use requestIdleCallback to defer HubSpot loading
hs.type = 'text/javascript'; const loadHubSpot = () => {
hs.id = 'hs-script-loader'; var hs = document.createElement('script');
hs.async = true; hs.type = 'text/javascript';
hs.defer = true; hs.id = 'hs-script-loader';
hs.src = '//js.hs-scripts.com/22348300.js'; hs.async = true;
document.body.appendChild(hs); hs.defer = true;
hs.src = '//js.hs-scripts.com/22348300.js';
document.body.appendChild(hs);
};
if ('requestIdleCallback' in window) {
requestIdleCallback(loadHubSpot, { timeout: 3000 });
} else {
setTimeout(loadHubSpot, 2000);
}
} }
</script> </script>
</body> </body>

View File

@@ -29,6 +29,7 @@ import { SuspenseFallback } from './components/suspense-fallback/suspense-fallba
* 4. Lazy loading - All route components loaded on demand * 4. Lazy loading - All route components loaded on demand
* 5. Suspense boundaries - Better loading states * 5. Suspense boundaries - Better loading states
* 6. Optimized guard components with memoization * 6. Optimized guard components with memoization
* 7. Deferred initialization - Non-critical operations moved to background
*/ */
const App: React.FC = memo(() => { const App: React.FC = memo(() => {
const themeMode = useAppSelector(state => state.themeReducer.mode); const themeMode = useAppSelector(state => state.themeReducer.mode);
@@ -37,8 +38,22 @@ const App: React.FC = memo(() => {
// Memoize mixpanel initialization to prevent re-initialization // Memoize mixpanel initialization to prevent re-initialization
const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []); const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []);
// Defer mixpanel initialization to not block initial render
useEffect(() => { useEffect(() => {
initMixpanel(mixpanelToken); const initializeMixpanel = () => {
try {
initMixpanel(mixpanelToken);
} catch (error) {
logger.error('Failed to initialize Mixpanel:', error);
}
};
// Use requestIdleCallback to defer mixpanel initialization
if ('requestIdleCallback' in window) {
requestIdleCallback(initializeMixpanel, { timeout: 2000 });
} else {
setTimeout(initializeMixpanel, 1000);
}
}, [mixpanelToken]); }, [mixpanelToken]);
// Memoize language change handler // Memoize language change handler
@@ -48,39 +63,54 @@ const App: React.FC = memo(() => {
}); });
}, []); }, []);
// Apply theme immediately to prevent flash
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', themeMode); document.documentElement.setAttribute('data-theme', themeMode);
}, [themeMode]); }, [themeMode]);
// Handle language changes
useEffect(() => { useEffect(() => {
handleLanguageChange(language || Language.EN); handleLanguageChange(language || Language.EN);
}, [language, handleLanguageChange]); }, [language, handleLanguageChange]);
// Initialize CSRF token and translations on app startup // Initialize critical app functionality
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const initializeApp = async () => { const initializeCriticalApp = async () => {
try { try {
// Initialize CSRF token // Initialize CSRF token immediately as it's needed for API calls
await initializeCsrfToken(); await initializeCsrfToken();
// Note: Translation preloading is handled in i18n.ts initialization
// No need to call ensureTranslationsLoaded here to avoid duplicate requests
} catch (error) { } catch (error) {
if (isMounted) { if (isMounted) {
logger.error('Failed to initialize app:', error); logger.error('Failed to initialize critical app functionality:', error);
} }
} }
}; };
initializeApp(); // Initialize critical functionality immediately
initializeCriticalApp();
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, []); }, []);
// Defer non-critical initialization
useEffect(() => {
const initializeNonCriticalApp = () => {
// Any non-critical initialization can go here
// For example: analytics, feature flags, etc.
};
// Defer non-critical initialization to not block initial render
if ('requestIdleCallback' in window) {
requestIdleCallback(initializeNonCriticalApp, { timeout: 3000 });
} else {
setTimeout(initializeNonCriticalApp, 1500);
}
}, []);
return ( return (
<Suspense fallback={<SuspenseFallback />}> <Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper> <ThemeWrapper>

View File

@@ -0,0 +1,84 @@
import { lazy, Suspense } from 'react';
import { Spin } from 'antd';
// Lazy load Chart.js components
const LazyBarChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Bar }))
);
const LazyLineChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Line }))
);
const LazyPieChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Pie }))
);
const LazyDoughnutChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Doughnut }))
);
// Lazy load Gantt components
const LazyGanttChart = lazy(() =>
import('gantt-task-react').then(module => ({ default: module.Gantt }))
);
// Chart loading fallback
const ChartLoadingFallback = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '300px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
<Spin size="large" />
</div>
);
// Wrapped components with Suspense
export const BarChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyBarChart {...props} />
</Suspense>
);
export const LineChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyLineChart {...props} />
</Suspense>
);
export const PieChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyPieChart {...props} />
</Suspense>
);
export const DoughnutChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyDoughnutChart {...props} />
</Suspense>
);
export const GanttChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyGanttChart {...props} />
</Suspense>
);
// Hook to preload chart libraries when needed
export const usePreloadCharts = () => {
const preloadCharts = () => {
// Preload Chart.js
import('react-chartjs-2');
import('chart.js');
// Preload Gantt
import('gantt-task-react');
};
return { preloadCharts };
};

View File

@@ -1,10 +1,14 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect, lazy, Suspense } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useSocket } from '@/socket/socketContext'; import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events'; import { SocketEvents } from '@/shared/socket-events';
// Lazy load TinyMCE editor to reduce initial bundle size
const LazyTinyMCEEditor = lazy(() =>
import('@tinymce/tinymce-react').then(module => ({ default: module.Editor }))
);
interface DescriptionEditorProps { interface DescriptionEditorProps {
description: string | null; description: string | null;
taskId: string; taskId: string;
@@ -17,23 +21,39 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false); const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [content, setContent] = useState<string>(description || ''); const [content, setContent] = useState<string>(description || '');
const [isEditorLoading, setIsEditorLoading] = useState<boolean>(false); const [isEditorLoading, setIsEditorLoading] = useState<boolean>(false);
const [wordCount, setWordCount] = useState<number>(0); // State for word count const [wordCount, setWordCount] = useState<number>(0);
const [isTinyMCELoaded, setIsTinyMCELoaded] = useState<boolean>(false);
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode); const themeMode = useAppSelector(state => state.themeReducer.mode);
// Preload TinyMCE script // Load TinyMCE script only when editor is opened
useEffect(() => { const loadTinyMCE = async () => {
const preloadTinyMCE = () => { if (isTinyMCELoaded) return;
const link = document.createElement('link');
link.rel = 'preload'; setIsEditorLoading(true);
link.href = '/tinymce/tinymce.min.js'; try {
link.as = 'script'; // Load TinyMCE script dynamically
document.head.appendChild(link); await new Promise<void>((resolve, reject) => {
}; if (window.tinymce) {
resolve();
preloadTinyMCE(); return;
}, []); }
const script = document.createElement('script');
script.src = '/tinymce/tinymce.min.js';
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load TinyMCE'));
document.head.appendChild(script);
});
setIsTinyMCELoaded(true);
} catch (error) {
console.error('Failed to load TinyMCE:', error);
setIsEditorLoading(false);
}
};
const handleDescriptionChange = () => { const handleDescriptionChange = () => {
if (!taskId) return; if (!taskId) return;
@@ -80,7 +100,6 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
const handleEditorChange = (content: string) => { const handleEditorChange = (content: string) => {
const sanitizedContent = DOMPurify.sanitize(content); const sanitizedContent = DOMPurify.sanitize(content);
setContent(sanitizedContent); setContent(sanitizedContent);
// Update word count when content changes
if (editorRef.current) { if (editorRef.current) {
const count = editorRef.current.plugins.wordcount.getCount(); const count = editorRef.current.plugins.wordcount.getCount();
setWordCount(count); setWordCount(count);
@@ -90,15 +109,14 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
const handleInit = (evt: any, editor: any) => { const handleInit = (evt: any, editor: any) => {
editorRef.current = editor; editorRef.current = editor;
editor.on('focus', () => setIsEditorOpen(true)); editor.on('focus', () => setIsEditorOpen(true));
// Set initial word count on init
const initialCount = editor.plugins.wordcount.getCount(); const initialCount = editor.plugins.wordcount.getCount();
setWordCount(initialCount); setWordCount(initialCount);
setIsEditorLoading(false); setIsEditorLoading(false);
}; };
const handleOpenEditor = () => { const handleOpenEditor = async () => {
setIsEditorOpen(true); setIsEditorOpen(true);
setIsEditorLoading(true); await loadTinyMCE();
}; };
const darkModeStyles = const darkModeStyles =
@@ -141,59 +159,63 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
<div>Loading editor...</div> <div>Loading editor...</div>
</div> </div>
)} )}
<Editor {isTinyMCELoaded && (
tinymceScriptSrc="/tinymce/tinymce.min.js" <Suspense fallback={<div>Loading editor...</div>}>
value={content} <LazyTinyMCEEditor
onInit={handleInit} tinymceScriptSrc="/tinymce/tinymce.min.js"
licenseKey="gpl" value={content}
init={{ onInit={handleInit}
height: 200, licenseKey="gpl"
menubar: false, init={{
branding: false, height: 200,
plugins: [ menubar: false,
'advlist', branding: false,
'autolink', plugins: [
'lists', 'advlist',
'link', 'autolink',
'charmap', 'lists',
'preview', 'link',
'anchor', 'charmap',
'searchreplace', 'preview',
'visualblocks', 'anchor',
'code', 'searchreplace',
'fullscreen', 'visualblocks',
'insertdatetime', 'code',
'media', 'fullscreen',
'table', 'insertdatetime',
'code', 'media',
'wordcount', // Added wordcount 'table',
], 'code',
toolbar: 'wordcount',
'blocks |' + ],
'bold italic underline strikethrough | ' + toolbar:
'bullist numlist | link | removeformat | help', 'blocks |' +
content_style: ` 'bold italic underline strikethrough | ' +
body { 'bullist numlist | link | removeformat | help',
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; content_style: `
font-size: 14px; body {
} font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
${darkModeStyles} font-size: 14px;
`, }
skin: themeMode === 'dark' ? 'oxide-dark' : 'oxide', ${darkModeStyles}
content_css: themeMode === 'dark' ? 'dark' : 'default', `,
skin_url: `/tinymce/skins/ui/${themeMode === 'dark' ? 'oxide-dark' : 'oxide'}`, skin: themeMode === 'dark' ? 'oxide-dark' : 'oxide',
content_css_cors: true, content_css: themeMode === 'dark' ? 'dark' : 'default',
auto_focus: true, skin_url: `/tinymce/skins/ui/${themeMode === 'dark' ? 'oxide-dark' : 'oxide'}`,
init_instance_callback: editor => { content_css_cors: true,
editor.dom.setStyle( auto_focus: true,
editor.getBody(), init_instance_callback: editor => {
'backgroundColor', editor.dom.setStyle(
themeMode === 'dark' ? '#1e1e1e' : '#ffffff' editor.getBody(),
); 'backgroundColor',
}, themeMode === 'dark' ? '#1e1e1e' : '#ffffff'
}} );
onEditorChange={handleEditorChange} },
/> }}
onEditorChange={handleEditorChange}
/>
</Suspense>
)}
</div> </div>
) : ( ) : (
<div <div
@@ -201,24 +223,37 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
style={{ style={{
minHeight: '32px', minHeight: '40px',
padding: '4px 11px', padding: '8px 12px',
border: `1px solid ${isHovered ? (themeMode === 'dark' ? '#177ddc' : '#40a9ff') : 'transparent'}`, border: `1px solid ${themeMode === 'dark' ? '#424242' : '#d9d9d9'}`,
borderRadius: '6px', borderRadius: '6px',
cursor: 'pointer', cursor: 'pointer',
backgroundColor: isHovered
? themeMode === 'dark'
? '#2a2a2a'
: '#fafafa'
: themeMode === 'dark'
? '#1e1e1e'
: '#ffffff',
color: themeMode === 'dark' ? '#ffffff' : '#000000', color: themeMode === 'dark' ? '#ffffff' : '#000000',
transition: 'border-color 0.3s ease', transition: 'all 0.2s ease',
}} }}
> >
{content ? ( {content ? (
<div <div
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }} dangerouslySetInnerHTML={{
style={{ color: 'inherit' }} __html: DOMPurify.sanitize(content),
}}
/> />
) : ( ) : (
<span style={{ color: themeMode === 'dark' ? '#666666' : '#bfbfbf' }}> <div
Add a more detailed description... style={{
</span> color: themeMode === 'dark' ? '#888888' : '#999999',
fontStyle: 'italic',
}}
>
Click to add description...
</div>
)} )}
</div> </div>
)} )}

View File

@@ -6,16 +6,27 @@ import logger from './utils/errorLogger';
// Essential namespaces that should be preloaded to prevent Suspense // Essential namespaces that should be preloaded to prevent Suspense
const ESSENTIAL_NAMESPACES = [ const ESSENTIAL_NAMESPACES = [
'common', 'common',
'auth/login',
'navbar',
];
// Secondary namespaces that can be loaded on demand
const SECONDARY_NAMESPACES = [
'tasks/task-table-bulk-actions', 'tasks/task-table-bulk-actions',
'task-management', 'task-management',
'auth/login',
'settings', 'settings',
'home',
'project-drawer',
]; ];
// Cache to track loaded translations and prevent duplicate requests // Cache to track loaded translations and prevent duplicate requests
const loadedTranslations = new Set<string>(); const loadedTranslations = new Set<string>();
const loadingPromises = new Map<string, Promise<any>>(); const loadingPromises = new Map<string, Promise<any>>();
// Background loading queue for non-essential translations
let backgroundLoadingQueue: Array<{ lang: string; ns: string }> = [];
let isBackgroundLoading = false;
i18n i18n
.use(HttpApi) .use(HttpApi)
.use(initReactI18next) .use(initReactI18next)
@@ -23,24 +34,34 @@ i18n
fallbackLng: 'en', fallbackLng: 'en',
backend: { backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json', loadPath: '/locales/{{lng}}/{{ns}}.json',
// Add request timeout to prevent hanging on slow connections
requestOptions: {
cache: 'default',
mode: 'cors',
credentials: 'same-origin',
},
}, },
defaultNS: 'common', defaultNS: 'common',
// Only load essential namespaces initially
ns: ESSENTIAL_NAMESPACES, ns: ESSENTIAL_NAMESPACES,
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },
// Preload essential namespaces // Only preload current language to reduce initial load
preload: ['en', 'es', 'pt', 'alb', 'de'], preload: [],
// Load all namespaces on initialization
load: 'languageOnly', load: 'languageOnly',
// Cache translations // Disable loading all namespaces on init
initImmediate: false,
// Cache translations with shorter expiration for better performance
cache: { cache: {
enabled: true, enabled: true,
expirationTime: 24 * 60 * 60 * 1000, // 24 hours expirationTime: 12 * 60 * 60 * 1000, // 12 hours
}, },
// Reduce debug output in production
debug: process.env.NODE_ENV === 'development',
}); });
// Utility function to ensure translations are loaded // Optimized function to ensure translations are loaded
export const ensureTranslationsLoaded = async ( export const ensureTranslationsLoaded = async (
namespaces: string[] = ESSENTIAL_NAMESPACES, namespaces: string[] = ESSENTIAL_NAMESPACES,
languages: string[] = [i18n.language || 'en'] languages: string[] = [i18n.language || 'en']
@@ -65,7 +86,6 @@ export const ensureTranslationsLoaded = async (
// Create loading promise // Create loading promise
const loadingPromise = new Promise<void>((resolve, reject) => { const loadingPromise = new Promise<void>((resolve, reject) => {
// Switch to the target language temporarily if needed
const currentLang = i18n.language; const currentLang = i18n.language;
const shouldSwitchLang = currentLang !== lang; const shouldSwitchLang = currentLang !== lang;
@@ -77,7 +97,6 @@ export const ensureTranslationsLoaded = async (
await i18n.loadNamespaces(ns); await i18n.loadNamespaces(ns);
// Switch back to original language if we changed it
if (shouldSwitchLang && currentLang) { if (shouldSwitchLang && currentLang) {
await i18n.changeLanguage(currentLang); await i18n.changeLanguage(currentLang);
} }
@@ -100,7 +119,6 @@ export const ensureTranslationsLoaded = async (
} }
} }
// Wait for all loading promises to complete
await Promise.all(loadPromises); await Promise.all(loadPromises);
return true; return true;
} catch (error) { } catch (error) {
@@ -109,13 +127,86 @@ export const ensureTranslationsLoaded = async (
} }
}; };
// Preload essential translations for current language only on startup // Background loading function for non-essential translations
const initializeTranslations = async () => { const processBackgroundQueue = async () => {
const currentLang = i18n.language || 'en'; if (isBackgroundLoading || backgroundLoadingQueue.length === 0) return;
await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang]);
isBackgroundLoading = true;
try {
// Process queue in batches to avoid overwhelming the network
const batchSize = 3;
while (backgroundLoadingQueue.length > 0) {
const batch = backgroundLoadingQueue.splice(0, batchSize);
const batchPromises = batch.map(({ lang, ns }) =>
ensureTranslationsLoaded([ns], [lang]).catch(error => {
logger.error(`Background loading failed for ${lang}:${ns}`, error);
})
);
await Promise.all(batchPromises);
// Add small delay between batches to prevent blocking
if (backgroundLoadingQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
} finally {
isBackgroundLoading = false;
}
}; };
// Initialize translations on app startup (only once) // Queue secondary translations for background loading
const queueSecondaryTranslations = (language: string) => {
SECONDARY_NAMESPACES.forEach(ns => {
const key = `${language}:${ns}`;
if (!loadedTranslations.has(key)) {
backgroundLoadingQueue.push({ lang: language, ns });
}
});
// Start background loading with a delay to not interfere with initial render
setTimeout(processBackgroundQueue, 2000);
};
// Initialize only essential translations for current language
const initializeTranslations = async () => {
try {
const currentLang = i18n.language || 'en';
// Load only essential namespaces initially
await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang]);
// Queue secondary translations for background loading
queueSecondaryTranslations(currentLang);
return true;
} catch (error) {
logger.error('Failed to initialize translations:', error);
return false;
}
};
// Language change handler that prioritizes essential namespaces
export const changeLanguageOptimized = async (language: string) => {
try {
// Change language first
await i18n.changeLanguage(language);
// Load essential namespaces immediately
await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [language]);
// Queue secondary translations for background loading
queueSecondaryTranslations(language);
return true;
} catch (error) {
logger.error(`Failed to change language to ${language}:`, error);
return false;
}
};
// Initialize translations on app startup (only essential ones)
initializeTranslations(); initializeTranslations();
export default i18n; export default i18n;

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState, forwardRef, useImperativeHandle } from 'react'; import React, { useEffect, useState, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
import { Bar } from 'react-chartjs-2';
import { import {
Chart as ChartJS, Chart as ChartJS,
CategoryScale, CategoryScale,
@@ -20,7 +19,34 @@ import { IRPTTimeProject } from '@/types/reporting/reporting.types';
import { Empty, Spin } from 'antd'; import { Empty, Spin } from 'antd';
import logger from '@/utils/errorLogger'; import logger from '@/utils/errorLogger';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels); // Lazy load the Bar chart component
const LazyBarChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Bar }))
);
// Chart loading fallback
const ChartLoadingFallback = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '400px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
<Spin size="large" />
</div>
);
// Register Chart.js components only when needed
let isChartJSRegistered = false;
const registerChartJS = () => {
if (!isChartJSRegistered) {
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
isChartJSRegistered = true;
}
};
const BAR_THICKNESS = 40; const BAR_THICKNESS = 40;
const STROKE_WIDTH = 4; const STROKE_WIDTH = 4;
@@ -36,6 +62,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
const { t } = useTranslation('time-report'); const { t } = useTranslation('time-report');
const [jsonData, setJsonData] = useState<IRPTTimeProject[]>([]); const [jsonData, setJsonData] = useState<IRPTTimeProject[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [chartReady, setChartReady] = useState(false);
const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null); const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode); const themeMode = useAppSelector(state => state.themeReducer.mode);
@@ -51,6 +78,21 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
} = useAppSelector(state => state.timeReportsOverviewReducer); } = useAppSelector(state => state.timeReportsOverviewReducer);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer); const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
// Initialize chart when component mounts
useEffect(() => {
const initChart = () => {
registerChartJS();
setChartReady(true);
};
// Use requestIdleCallback to defer chart initialization
if ('requestIdleCallback' in window) {
requestIdleCallback(initChart, { timeout: 1000 });
} else {
setTimeout(initChart, 500);
}
}, []);
const handleBarClick = (event: any, elements: any) => { const handleBarClick = (event: any, elements: any) => {
if (elements.length > 0) { if (elements.length > 0) {
const elementIndex = elements[0].index; const elementIndex = elements[0].index;
@@ -158,7 +200,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
}; };
useEffect(() => { useEffect(() => {
if (!loadingTeams && !loadingProjects && !loadingCategories) { if (!loadingTeams && !loadingProjects && !loadingCategories && chartReady) {
setLoading(true); setLoading(true);
fetchChartData().finally(() => { fetchChartData().finally(() => {
setLoading(false); setLoading(false);
@@ -175,6 +217,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
loadingTeams, loadingTeams,
loadingProjects, loadingProjects,
loadingCategories, loadingCategories,
chartReady,
]); ]);
const exportChart = () => { const exportChart = () => {
@@ -200,8 +243,8 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
// Create download link // Create download link
const link = document.createElement('a'); const link = document.createElement('a');
link.download = 'project-time-sheet.png'; link.download = 'project-time-sheet-chart.png';
link.href = tempCanvas.toDataURL('image/png'); link.href = tempCanvas.toDataURL();
link.click(); link.click();
} }
}; };
@@ -210,25 +253,35 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
exportChart, exportChart,
})); }));
// if (loading) { if (loading) {
// return ( return (
// <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}> <div style={{ minHeight: MIN_HEIGHT }}>
// <Spin /> <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />
// </div> </div>
// ); );
// } }
if (!Array.isArray(jsonData) || jsonData.length === 0) {
return (
<div style={{ minHeight: MIN_HEIGHT }}>
<Empty description={t('noDataAvailable')} />
</div>
);
}
const chartHeight = jsonData.length * (BAR_THICKNESS + 10) + 100;
const containerHeight = Math.max(chartHeight, 400);
return ( return (
<div> <div style={{ minHeight: MIN_HEIGHT }}>
<div <div style={{ height: `${containerHeight}px`, width: '100%' }}>
style={{ {chartReady ? (
maxWidth: `calc(100vw - ${SIDEBAR_WIDTH}px)`, <Suspense fallback={<ChartLoadingFallback />}>
minWidth: 'calc(100vw - 260px)', <LazyBarChart data={data} options={options} ref={chartRef} />
minHeight: MIN_HEIGHT, </Suspense>
height: `${60 * data.labels.length}px`, ) : (
}} <ChartLoadingFallback />
> )}
<Bar data={data} options={options} ref={chartRef} />
</div> </div>
<ProjectTimeLogDrawer /> <ProjectTimeLogDrawer />
</div> </div>

View File

@@ -77,36 +77,90 @@ export default defineConfig(({ command, mode }) => {
// **Rollup Options** // **Rollup Options**
rollupOptions: { rollupOptions: {
output: { output: {
// **Simplified Chunking Strategy to avoid React context issues** // **Optimized Chunking Strategy for better caching and loading**
manualChunks: { manualChunks: (id) => {
// Keep React and all React-dependent libraries together // Core React libraries - most stable, rarely change
'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'], if (id.includes('react') || id.includes('react-dom') || id.includes('react/jsx-runtime')) {
return 'react-core';
// Separate chunk for router }
'react-router': ['react-router-dom'],
// React Router - separate chunk as it's used throughout the app
// Keep Ant Design separate but ensure React is available if (id.includes('react-router') || id.includes('react-router-dom')) {
antd: ['antd', '@ant-design/icons'], return 'react-router';
}
// Ant Design - large UI library, separate chunk
if (id.includes('antd') || id.includes('@ant-design')) {
return 'antd';
}
// Chart.js and related libraries - heavy visualization libs
if (id.includes('chart.js') || id.includes('react-chartjs') || id.includes('chartjs')) {
return 'charts';
}
// TinyMCE - heavy editor, separate chunk (lazy loaded)
if (id.includes('tinymce') || id.includes('@tinymce')) {
return 'tinymce';
}
// Gantt and scheduling libraries - heavy components
if (id.includes('gantt') || id.includes('scheduler')) {
return 'gantt';
}
// Date utilities - commonly used
if (id.includes('date-fns') || id.includes('moment')) {
return 'date-utils';
}
// Redux and state management
if (id.includes('@reduxjs') || id.includes('react-redux') || id.includes('redux')) {
return 'redux';
}
// Socket.io - real-time communication
if (id.includes('socket.io')) {
return 'socket';
}
// Utility libraries
if (id.includes('lodash') || id.includes('dompurify') || id.includes('nanoid')) {
return 'utils';
}
// i18n libraries
if (id.includes('i18next') || id.includes('react-i18next')) {
return 'i18n';
}
// Other node_modules dependencies
if (id.includes('node_modules')) {
return 'vendor';
}
// Return undefined for app code to be bundled together
return undefined;
}, },
// **File Naming Strategies** // **File Naming Strategies**
chunkFileNames: chunkInfo => { chunkFileNames: (chunkInfo) => {
const facadeModuleId = chunkInfo.facadeModuleId // Use shorter names for better caching
? chunkInfo.facadeModuleId.split('/').pop() return `assets/js/[name]-[hash:8].js`;
: 'chunk';
return `assets/js/[name]-[hash].js`;
}, },
entryFileNames: 'assets/js/[name]-[hash].js', entryFileNames: 'assets/js/[name]-[hash:8].js',
assetFileNames: assetInfo => { assetFileNames: (assetInfo) => {
if (!assetInfo.name) return 'assets/[name]-[hash].[ext]'; if (!assetInfo.name) return 'assets/[name]-[hash:8].[ext]';
const info = assetInfo.name.split('.'); const info = assetInfo.name.split('.');
let extType = info[info.length - 1]; let extType = info[info.length - 1];
if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) { if (/png|jpe?g|svg|gif|tiff|bmp|ico|webp/i.test(extType)) {
extType = 'img'; extType = 'img';
} else if (/woff2?|eot|ttf|otf/i.test(extType)) { } else if (/woff2?|eot|ttf|otf/i.test(extType)) {
extType = 'fonts'; extType = 'fonts';
} else if (/css/i.test(extType)) {
extType = 'css';
} }
return `assets/${extType}/[name]-[hash].[ext]`; return `assets/${extType}/[name]-[hash:8].[ext]`;
}, },
}, },
@@ -126,17 +180,35 @@ export default defineConfig(({ command, mode }) => {
// **Optimization** // **Optimization**
optimizeDeps: { optimizeDeps: {
include: ['react', 'react-dom', 'react/jsx-runtime', 'antd', '@ant-design/icons'], include: [
'react',
'react-dom',
'react/jsx-runtime',
'antd',
'@ant-design/icons',
'react-router-dom',
'i18next',
'react-i18next',
'date-fns',
'dompurify',
],
exclude: [ exclude: [
// Add any packages that should not be pre-bundled // Exclude heavy libraries that should be lazy loaded
'@tinymce/tinymce-react',
'tinymce',
'chart.js',
'react-chartjs-2',
'gantt-task-react',
], ],
// Force pre-bundling to avoid runtime issues // Force pre-bundling to avoid runtime issues
force: true, force: false, // Only force when needed to improve dev startup time
}, },
// **Define global constants** // **Define global constants**
define: { define: {
__DEV__: !isProduction, __DEV__: !isProduction,
}, },
}; };
}); });