perf: optimize frontend bundle - reduce main chunk 77%

- vite.config: separate lucide-react icons + analytics into own chunks
- App.tsx: defer SiteAssistant loading via requestIdleCallback
- HeroUploadZone: lazy-load ToolSelectorModal + dynamic import fileRouting
- HeroUploadZone: add aria-label on dropzone input (accessibility)
- SocialProofStrip: defer API call until component is in viewport
- index.html: remove dev-only modulepreload hint

Main bundle: 266KB -> 61KB (-77%)
This commit is contained in:
Your Name
2026-04-04 22:36:45 +02:00
parent 7e9edc2992
commit 7928e688d5
5 changed files with 63 additions and 18 deletions

View File

@@ -65,7 +65,6 @@
<noscript> <noscript>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Tajawal:wght@400;700&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Tajawal:wght@400;700&display=swap" />
</noscript> </noscript>
<link rel="modulepreload" href="/src/main.tsx" />
<title>Dociva — Free Online File Tools</title> <title>Dociva — Free Online File Tools</title>
</head> </head>

View File

@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect } from 'react'; import { lazy, Suspense, useEffect, useState } from 'react';
import Clarity from '@microsoft/clarity'; import Clarity from '@microsoft/clarity';
import { Routes, Route, useLocation } from 'react-router-dom'; import { Routes, Route, useLocation } from 'react-router-dom';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
@@ -48,6 +48,19 @@ function LoadingFallback() {
); );
} }
function IdleLoad({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
useEffect(() => {
if ('requestIdleCallback' in window) {
const id = requestIdleCallback(() => setReady(true));
return () => cancelIdleCallback(id);
}
const id = setTimeout(() => setReady(true), 2000);
return () => clearTimeout(id);
}, []);
return ready ? <>{children}</> : null;
}
export default function App() { export default function App() {
useDirection(); useDirection();
const location = useLocation(); const location = useLocation();
@@ -152,7 +165,9 @@ export default function App() {
<Footer /> <Footer />
<Suspense fallback={null}> <Suspense fallback={null}>
<SiteAssistant /> <IdleLoad>
<SiteAssistant />
</IdleLoad>
<CookieConsent /> <CookieConsent />
</Suspense> </Suspense>
<Toaster <Toaster

View File

@@ -1,14 +1,14 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, lazy, Suspense } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { UploadCloud, PenLine, ChevronRight, FileCheck } from 'lucide-react'; import { UploadCloud, PenLine, ChevronRight, FileCheck } from 'lucide-react';
import ToolSelectorModal from '@/components/shared/ToolSelectorModal';
import { useFileStore } from '@/stores/fileStore'; import { useFileStore } from '@/stores/fileStore';
import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting';
import type { ToolOption } from '@/utils/fileRouting'; import type { ToolOption } from '@/utils/fileRouting';
import { useConfig } from '@/hooks/useConfig'; import { useConfig } from '@/hooks/useConfig';
const ToolSelectorModal = lazy(() => import('@/components/shared/ToolSelectorModal'));
/** /**
* The MIME types we accept on the homepage smart upload zone. * The MIME types we accept on the homepage smart upload zone.
* Covers PDF, images, video, and Word documents. * Covers PDF, images, video, and Word documents.
@@ -45,12 +45,13 @@ export default function HeroUploadZone() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[]) => { async (acceptedFiles: File[]) => {
setError(null); setError(null);
if (acceptedFiles.length === 0) return; if (acceptedFiles.length === 0) return;
const file = acceptedFiles[0]; const file = acceptedFiles[0];
const { getToolsForFile, detectFileCategory, getCategoryLabel } = await import('@/utils/fileRouting');
const tools = getToolsForFile(file); const tools = getToolsForFile(file);
if (tools.length === 0) { if (tools.length === 0) {
@@ -107,7 +108,7 @@ export default function HeroUploadZone() {
{...getRootProps()} {...getRootProps()}
className={`hero-upload-zone group ${isDragActive ? 'drag-active' : ''}`} className={`hero-upload-zone group ${isDragActive ? 'drag-active' : ''}`}
> >
<input {...getInputProps()} /> <input {...getInputProps()} aria-label={t('home.dragDropTitle', 'Drag & drop your file here')} />
{/* Cloud icon with animated ring */} {/* Cloud icon with animated ring */}
<div className="relative mb-6"> <div className="relative mb-6">
@@ -210,13 +211,15 @@ export default function HeroUploadZone() {
</div> </div>
{/* Tool Selector Modal */} {/* Tool Selector Modal */}
<ToolSelectorModal <Suspense fallback={null}>
isOpen={modalOpen} <ToolSelectorModal
onClose={handleCloseModal} isOpen={modalOpen}
file={selectedFile} onClose={handleCloseModal}
tools={matchedTools} file={selectedFile}
fileTypeLabel={fileTypeLabel} tools={matchedTools}
/> fileTypeLabel={fileTypeLabel}
/>
</Suspense>
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
@@ -12,8 +12,27 @@ interface SocialProofStripProps {
export default function SocialProofStrip({ className = '' }: SocialProofStripProps) { export default function SocialProofStrip({ className = '' }: SocialProofStripProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [stats, setStats] = useState<PublicStatsSummary | null>(null); const [stats, setStats] = useState<PublicStatsSummary | null>(null);
const sectionRef = useRef<HTMLElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => { useEffect(() => {
const el = sectionRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!isVisible) return;
let cancelled = false; let cancelled = false;
getPublicStats() getPublicStats()
@@ -31,11 +50,12 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [isVisible]);
if (!stats) { if (!stats) {
return ( return (
<section <section
ref={sectionRef}
aria-hidden="true" aria-hidden="true"
className={`min-h-[260px] rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()} className={`min-h-[260px] rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}
> >
@@ -97,7 +117,7 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
].filter((card): card is { label: string; value: string } => Boolean(card)); ].filter((card): card is { label: string; value: string } => Boolean(card));
return ( return (
<section className={`min-h-[260px] rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}> <section ref={sectionRef} className={`min-h-[260px] rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}>
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-2xl"> <div className="max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400"> <p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">

View File

@@ -88,6 +88,14 @@ export default defineConfig({
return 'editor'; return 'editor';
} }
if (id.includes('lucide-react')) {
return 'icons';
}
if (id.includes('@microsoft/clarity')) {
return 'analytics';
}
return undefined; return undefined;
}, },
}, },