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:
@@ -5,43 +5,74 @@
|
||||
<link rel="icon" href="./favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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.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
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
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>
|
||||
|
||||
<!-- Environment configuration -->
|
||||
<script src="/env-config.js"></script>
|
||||
<!-- Google Analytics -->
|
||||
|
||||
<!-- Optimized Google Analytics with reduced blocking -->
|
||||
<script>
|
||||
// Function to initialize Google Analytics
|
||||
// Function to initialize Google Analytics asynchronously
|
||||
function initGoogleAnalytics() {
|
||||
// Load the Google Analytics script
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
// Use requestIdleCallback to defer analytics loading
|
||||
const loadAnalytics = () => {
|
||||
// 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 isProduction =
|
||||
window.location.hostname === 'worklenz.com' ||
|
||||
window.location.hostname === 'app.worklenz.com';
|
||||
const trackingId = isProduction ? 'G-XXXXXXXXXX' : 'G-3LM2HGWEXG'; // Open source tracking ID
|
||||
|
||||
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}`;
|
||||
document.head.appendChild(script);
|
||||
// Initialize Google Analytics
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
gtag('config', trackingId);
|
||||
};
|
||||
|
||||
// Initialize Google Analytics
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
// Use requestIdleCallback if available, otherwise setTimeout
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(loadAnalytics, { timeout: 2000 });
|
||||
} else {
|
||||
setTimeout(loadAnalytics, 1000);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
gtag('config', trackingId);
|
||||
}
|
||||
|
||||
// Initialize analytics
|
||||
// Initialize analytics after a delay to not block initial render
|
||||
initGoogleAnalytics();
|
||||
|
||||
// Function to show privacy notice
|
||||
@@ -98,14 +129,24 @@
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
<script type="text/javascript">
|
||||
// Load HubSpot script asynchronously and only for production
|
||||
if (window.location.hostname === 'app.worklenz.com') {
|
||||
var hs = document.createElement('script');
|
||||
hs.type = 'text/javascript';
|
||||
hs.id = 'hs-script-loader';
|
||||
hs.async = true;
|
||||
hs.defer = true;
|
||||
hs.src = '//js.hs-scripts.com/22348300.js';
|
||||
document.body.appendChild(hs);
|
||||
// Use requestIdleCallback to defer HubSpot loading
|
||||
const loadHubSpot = () => {
|
||||
var hs = document.createElement('script');
|
||||
hs.type = 'text/javascript';
|
||||
hs.id = 'hs-script-loader';
|
||||
hs.async = true;
|
||||
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>
|
||||
</body>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { SuspenseFallback } from './components/suspense-fallback/suspense-fallba
|
||||
* 4. Lazy loading - All route components loaded on demand
|
||||
* 5. Suspense boundaries - Better loading states
|
||||
* 6. Optimized guard components with memoization
|
||||
* 7. Deferred initialization - Non-critical operations moved to background
|
||||
*/
|
||||
const App: React.FC = memo(() => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
@@ -37,8 +38,22 @@ const App: React.FC = memo(() => {
|
||||
// Memoize mixpanel initialization to prevent re-initialization
|
||||
const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []);
|
||||
|
||||
// Defer mixpanel initialization to not block initial render
|
||||
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]);
|
||||
|
||||
// Memoize language change handler
|
||||
@@ -48,39 +63,54 @@ const App: React.FC = memo(() => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Apply theme immediately to prevent flash
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', themeMode);
|
||||
}, [themeMode]);
|
||||
|
||||
// Handle language changes
|
||||
useEffect(() => {
|
||||
handleLanguageChange(language || Language.EN);
|
||||
}, [language, handleLanguageChange]);
|
||||
|
||||
// Initialize CSRF token and translations on app startup
|
||||
// Initialize critical app functionality
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const initializeApp = async () => {
|
||||
const initializeCriticalApp = async () => {
|
||||
try {
|
||||
// Initialize CSRF token
|
||||
// Initialize CSRF token immediately as it's needed for API calls
|
||||
await initializeCsrfToken();
|
||||
|
||||
// Note: Translation preloading is handled in i18n.ts initialization
|
||||
// No need to call ensureTranslationsLoaded here to avoid duplicate requests
|
||||
} catch (error) {
|
||||
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 () => {
|
||||
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 (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ThemeWrapper>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import React, { useState, useRef, useEffect, lazy, Suspense } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
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 {
|
||||
description: string | null;
|
||||
taskId: string;
|
||||
@@ -17,23 +21,39 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
|
||||
const [content, setContent] = useState<string>(description || '');
|
||||
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 wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// Preload TinyMCE script
|
||||
useEffect(() => {
|
||||
const preloadTinyMCE = () => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'preload';
|
||||
link.href = '/tinymce/tinymce.min.js';
|
||||
link.as = 'script';
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
preloadTinyMCE();
|
||||
}, []);
|
||||
// Load TinyMCE script only when editor is opened
|
||||
const loadTinyMCE = async () => {
|
||||
if (isTinyMCELoaded) return;
|
||||
|
||||
setIsEditorLoading(true);
|
||||
try {
|
||||
// Load TinyMCE script dynamically
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (window.tinymce) {
|
||||
resolve();
|
||||
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 = () => {
|
||||
if (!taskId) return;
|
||||
@@ -80,7 +100,6 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
const handleEditorChange = (content: string) => {
|
||||
const sanitizedContent = DOMPurify.sanitize(content);
|
||||
setContent(sanitizedContent);
|
||||
// Update word count when content changes
|
||||
if (editorRef.current) {
|
||||
const count = editorRef.current.plugins.wordcount.getCount();
|
||||
setWordCount(count);
|
||||
@@ -90,15 +109,14 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
const handleInit = (evt: any, editor: any) => {
|
||||
editorRef.current = editor;
|
||||
editor.on('focus', () => setIsEditorOpen(true));
|
||||
// Set initial word count on init
|
||||
const initialCount = editor.plugins.wordcount.getCount();
|
||||
setWordCount(initialCount);
|
||||
setIsEditorLoading(false);
|
||||
};
|
||||
|
||||
const handleOpenEditor = () => {
|
||||
const handleOpenEditor = async () => {
|
||||
setIsEditorOpen(true);
|
||||
setIsEditorLoading(true);
|
||||
await loadTinyMCE();
|
||||
};
|
||||
|
||||
const darkModeStyles =
|
||||
@@ -141,59 +159,63 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
<div>Loading editor...</div>
|
||||
</div>
|
||||
)}
|
||||
<Editor
|
||||
tinymceScriptSrc="/tinymce/tinymce.min.js"
|
||||
value={content}
|
||||
onInit={handleInit}
|
||||
licenseKey="gpl"
|
||||
init={{
|
||||
height: 200,
|
||||
menubar: false,
|
||||
branding: false,
|
||||
plugins: [
|
||||
'advlist',
|
||||
'autolink',
|
||||
'lists',
|
||||
'link',
|
||||
'charmap',
|
||||
'preview',
|
||||
'anchor',
|
||||
'searchreplace',
|
||||
'visualblocks',
|
||||
'code',
|
||||
'fullscreen',
|
||||
'insertdatetime',
|
||||
'media',
|
||||
'table',
|
||||
'code',
|
||||
'wordcount', // Added wordcount
|
||||
],
|
||||
toolbar:
|
||||
'blocks |' +
|
||||
'bold italic underline strikethrough | ' +
|
||||
'bullist numlist | link | removeformat | help',
|
||||
content_style: `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
${darkModeStyles}
|
||||
`,
|
||||
skin: themeMode === 'dark' ? 'oxide-dark' : 'oxide',
|
||||
content_css: themeMode === 'dark' ? 'dark' : 'default',
|
||||
skin_url: `/tinymce/skins/ui/${themeMode === 'dark' ? 'oxide-dark' : 'oxide'}`,
|
||||
content_css_cors: true,
|
||||
auto_focus: true,
|
||||
init_instance_callback: editor => {
|
||||
editor.dom.setStyle(
|
||||
editor.getBody(),
|
||||
'backgroundColor',
|
||||
themeMode === 'dark' ? '#1e1e1e' : '#ffffff'
|
||||
);
|
||||
},
|
||||
}}
|
||||
onEditorChange={handleEditorChange}
|
||||
/>
|
||||
{isTinyMCELoaded && (
|
||||
<Suspense fallback={<div>Loading editor...</div>}>
|
||||
<LazyTinyMCEEditor
|
||||
tinymceScriptSrc="/tinymce/tinymce.min.js"
|
||||
value={content}
|
||||
onInit={handleInit}
|
||||
licenseKey="gpl"
|
||||
init={{
|
||||
height: 200,
|
||||
menubar: false,
|
||||
branding: false,
|
||||
plugins: [
|
||||
'advlist',
|
||||
'autolink',
|
||||
'lists',
|
||||
'link',
|
||||
'charmap',
|
||||
'preview',
|
||||
'anchor',
|
||||
'searchreplace',
|
||||
'visualblocks',
|
||||
'code',
|
||||
'fullscreen',
|
||||
'insertdatetime',
|
||||
'media',
|
||||
'table',
|
||||
'code',
|
||||
'wordcount',
|
||||
],
|
||||
toolbar:
|
||||
'blocks |' +
|
||||
'bold italic underline strikethrough | ' +
|
||||
'bullist numlist | link | removeformat | help',
|
||||
content_style: `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
${darkModeStyles}
|
||||
`,
|
||||
skin: themeMode === 'dark' ? 'oxide-dark' : 'oxide',
|
||||
content_css: themeMode === 'dark' ? 'dark' : 'default',
|
||||
skin_url: `/tinymce/skins/ui/${themeMode === 'dark' ? 'oxide-dark' : 'oxide'}`,
|
||||
content_css_cors: true,
|
||||
auto_focus: true,
|
||||
init_instance_callback: editor => {
|
||||
editor.dom.setStyle(
|
||||
editor.getBody(),
|
||||
'backgroundColor',
|
||||
themeMode === 'dark' ? '#1e1e1e' : '#ffffff'
|
||||
);
|
||||
},
|
||||
}}
|
||||
onEditorChange={handleEditorChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -201,24 +223,37 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
minHeight: '32px',
|
||||
padding: '4px 11px',
|
||||
border: `1px solid ${isHovered ? (themeMode === 'dark' ? '#177ddc' : '#40a9ff') : 'transparent'}`,
|
||||
minHeight: '40px',
|
||||
padding: '8px 12px',
|
||||
border: `1px solid ${themeMode === 'dark' ? '#424242' : '#d9d9d9'}`,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isHovered
|
||||
? themeMode === 'dark'
|
||||
? '#2a2a2a'
|
||||
: '#fafafa'
|
||||
: themeMode === 'dark'
|
||||
? '#1e1e1e'
|
||||
: '#ffffff',
|
||||
color: themeMode === 'dark' ? '#ffffff' : '#000000',
|
||||
transition: 'border-color 0.3s ease',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{content ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }}
|
||||
style={{ color: 'inherit' }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(content),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: themeMode === 'dark' ? '#666666' : '#bfbfbf' }}>
|
||||
Add a more detailed description...
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
color: themeMode === 'dark' ? '#888888' : '#999999',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
Click to add description...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,16 +6,27 @@ import logger from './utils/errorLogger';
|
||||
// Essential namespaces that should be preloaded to prevent Suspense
|
||||
const ESSENTIAL_NAMESPACES = [
|
||||
'common',
|
||||
'auth/login',
|
||||
'navbar',
|
||||
];
|
||||
|
||||
// Secondary namespaces that can be loaded on demand
|
||||
const SECONDARY_NAMESPACES = [
|
||||
'tasks/task-table-bulk-actions',
|
||||
'task-management',
|
||||
'auth/login',
|
||||
'settings',
|
||||
'home',
|
||||
'project-drawer',
|
||||
];
|
||||
|
||||
// Cache to track loaded translations and prevent duplicate requests
|
||||
const loadedTranslations = new Set<string>();
|
||||
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
|
||||
.use(HttpApi)
|
||||
.use(initReactI18next)
|
||||
@@ -23,24 +34,34 @@ i18n
|
||||
fallbackLng: 'en',
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||
// Add request timeout to prevent hanging on slow connections
|
||||
requestOptions: {
|
||||
cache: 'default',
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin',
|
||||
},
|
||||
},
|
||||
defaultNS: 'common',
|
||||
// Only load essential namespaces initially
|
||||
ns: ESSENTIAL_NAMESPACES,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
// Preload essential namespaces
|
||||
preload: ['en', 'es', 'pt', 'alb', 'de'],
|
||||
// Load all namespaces on initialization
|
||||
// Only preload current language to reduce initial load
|
||||
preload: [],
|
||||
load: 'languageOnly',
|
||||
// Cache translations
|
||||
// Disable loading all namespaces on init
|
||||
initImmediate: false,
|
||||
// Cache translations with shorter expiration for better performance
|
||||
cache: {
|
||||
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 (
|
||||
namespaces: string[] = ESSENTIAL_NAMESPACES,
|
||||
languages: string[] = [i18n.language || 'en']
|
||||
@@ -65,7 +86,6 @@ export const ensureTranslationsLoaded = async (
|
||||
|
||||
// Create loading promise
|
||||
const loadingPromise = new Promise<void>((resolve, reject) => {
|
||||
// Switch to the target language temporarily if needed
|
||||
const currentLang = i18n.language;
|
||||
const shouldSwitchLang = currentLang !== lang;
|
||||
|
||||
@@ -77,7 +97,6 @@ export const ensureTranslationsLoaded = async (
|
||||
|
||||
await i18n.loadNamespaces(ns);
|
||||
|
||||
// Switch back to original language if we changed it
|
||||
if (shouldSwitchLang && currentLang) {
|
||||
await i18n.changeLanguage(currentLang);
|
||||
}
|
||||
@@ -100,7 +119,6 @@ export const ensureTranslationsLoaded = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all loading promises to complete
|
||||
await Promise.all(loadPromises);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -109,13 +127,86 @@ export const ensureTranslationsLoaded = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Preload essential translations for current language only on startup
|
||||
const initializeTranslations = async () => {
|
||||
const currentLang = i18n.language || 'en';
|
||||
await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang]);
|
||||
// Background loading function for non-essential translations
|
||||
const processBackgroundQueue = async () => {
|
||||
if (isBackgroundLoading || backgroundLoadingQueue.length === 0) return;
|
||||
|
||||
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();
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import React, { useEffect, useState, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
@@ -20,7 +19,34 @@ import { IRPTTimeProject } from '@/types/reporting/reporting.types';
|
||||
import { Empty, Spin } from 'antd';
|
||||
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 STROKE_WIDTH = 4;
|
||||
@@ -36,6 +62,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const [jsonData, setJsonData] = useState<IRPTTimeProject[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [chartReady, setChartReady] = useState(false);
|
||||
const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
@@ -51,6 +78,21 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
} = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
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) => {
|
||||
if (elements.length > 0) {
|
||||
const elementIndex = elements[0].index;
|
||||
@@ -158,7 +200,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingTeams && !loadingProjects && !loadingCategories) {
|
||||
if (!loadingTeams && !loadingProjects && !loadingCategories && chartReady) {
|
||||
setLoading(true);
|
||||
fetchChartData().finally(() => {
|
||||
setLoading(false);
|
||||
@@ -175,6 +217,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
loadingTeams,
|
||||
loadingProjects,
|
||||
loadingCategories,
|
||||
chartReady,
|
||||
]);
|
||||
|
||||
const exportChart = () => {
|
||||
@@ -200,8 +243,8 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
|
||||
// Create download link
|
||||
const link = document.createElement('a');
|
||||
link.download = 'project-time-sheet.png';
|
||||
link.href = tempCanvas.toDataURL('image/png');
|
||||
link.download = 'project-time-sheet-chart.png';
|
||||
link.href = tempCanvas.toDataURL();
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
@@ -210,25 +253,35 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
|
||||
exportChart,
|
||||
}));
|
||||
|
||||
// if (loading) {
|
||||
// return (
|
||||
// <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
// <Spin />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ minHeight: MIN_HEIGHT }}>
|
||||
<Spin size="large" style={{ display: 'block', margin: '100px auto' }} />
|
||||
</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 (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: `calc(100vw - ${SIDEBAR_WIDTH}px)`,
|
||||
minWidth: 'calc(100vw - 260px)',
|
||||
minHeight: MIN_HEIGHT,
|
||||
height: `${60 * data.labels.length}px`,
|
||||
}}
|
||||
>
|
||||
<Bar data={data} options={options} ref={chartRef} />
|
||||
<div style={{ minHeight: MIN_HEIGHT }}>
|
||||
<div style={{ height: `${containerHeight}px`, width: '100%' }}>
|
||||
{chartReady ? (
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<LazyBarChart data={data} options={options} ref={chartRef} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<ChartLoadingFallback />
|
||||
)}
|
||||
</div>
|
||||
<ProjectTimeLogDrawer />
|
||||
</div>
|
||||
|
||||
@@ -77,36 +77,90 @@ export default defineConfig(({ command, mode }) => {
|
||||
// **Rollup Options**
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// **Simplified Chunking Strategy to avoid React context issues**
|
||||
manualChunks: {
|
||||
// Keep React and all React-dependent libraries together
|
||||
'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
|
||||
// Separate chunk for router
|
||||
'react-router': ['react-router-dom'],
|
||||
|
||||
// Keep Ant Design separate but ensure React is available
|
||||
antd: ['antd', '@ant-design/icons'],
|
||||
// **Optimized Chunking Strategy for better caching and loading**
|
||||
manualChunks: (id) => {
|
||||
// Core React libraries - most stable, rarely change
|
||||
if (id.includes('react') || id.includes('react-dom') || id.includes('react/jsx-runtime')) {
|
||||
return 'react-core';
|
||||
}
|
||||
|
||||
// React Router - separate chunk as it's used throughout the app
|
||||
if (id.includes('react-router') || id.includes('react-router-dom')) {
|
||||
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**
|
||||
chunkFileNames: chunkInfo => {
|
||||
const facadeModuleId = chunkInfo.facadeModuleId
|
||||
? chunkInfo.facadeModuleId.split('/').pop()
|
||||
: 'chunk';
|
||||
return `assets/js/[name]-[hash].js`;
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
// Use shorter names for better caching
|
||||
return `assets/js/[name]-[hash:8].js`;
|
||||
},
|
||||
entryFileNames: 'assets/js/[name]-[hash].js',
|
||||
assetFileNames: assetInfo => {
|
||||
if (!assetInfo.name) return 'assets/[name]-[hash].[ext]';
|
||||
entryFileNames: 'assets/js/[name]-[hash:8].js',
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (!assetInfo.name) return 'assets/[name]-[hash:8].[ext]';
|
||||
const info = assetInfo.name.split('.');
|
||||
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';
|
||||
} else if (/woff2?|eot|ttf|otf/i.test(extType)) {
|
||||
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**
|
||||
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: [
|
||||
// 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: true,
|
||||
force: false, // Only force when needed to improve dev startup time
|
||||
},
|
||||
|
||||
// **Define global constants**
|
||||
define: {
|
||||
__DEV__: !isProduction,
|
||||
},
|
||||
|
||||
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user