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