frontend/src/App.tsx
Rama main · réplica de lectura del repositorio original.
import { Suspense, lazy, useEffect, useState } from "react";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
import { AppShell } from "./components/AppShell";
import { AppProviders } from "./components/AppProviders";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { useGlobalErrorHandler } from "./hooks/useGlobalErrorHandler";
import { notifyNavigation, startPageviewTracking } from "./lib/analyticsBeacon";
import {
prefetchRoute,
loadToolPage,
loadCompressUI,
loadMergeUI,
loadSplitUI,
} from "./lib/prefetch";
// @tanstack/react-query is in package.json but no component in the app uses
// useQuery / useMutation. The QueryClientProvider wrapper here used to be
// scaffolding from the project template. Removing the provider drops the
// 25 KB / 7.8 KB gz vendor-query chunk from every first-paint without
// changing behavior. If you later add server-state caching, re-add the
// provider locally inside the route that needs it (don't promote it back
// to the App root unless many components share queries).
const Index = lazy(() => import("./pages/Index"));
const ToolPage = lazy(() => import("./pages/ToolPage"));
const NonPdfToolPage = lazy(() => import("./pages/NonPdfToolPage"));
const AllToolsPage = lazy(() => import("./pages/AllToolsPage"));
const LandingPage = lazy(() => import("./pages/LandingPage"));
const AboutPage = lazy(() => import("./pages/AboutPage"));
const ComparePage = lazy(() => import("./pages/ComparePage"));
const NotFound = lazy(() => import("./pages/NotFound"));
const BatchPage = lazy(() => import("./pages/BatchPage"));
const PipelinePage = lazy(() => import("./pages/PipelinePage"));
const BlogPage = lazy(() => import("./pages/BlogPage"));
const BlogPostPage = lazy(() => import("./pages/BlogPostPage"));
const PrivacyPage = lazy(() => import("./pages/PrivacyPage"));
const TermsPage = lazy(() => import("./pages/TermsPage"));
const SecurityPage = lazy(() => import("./pages/SecurityPage"));
const MyStuffPage = lazy(() => import("./pages/MyStuffPage"));
const AccountPage = lazy(() => import("./pages/AccountPage"));
const VaultPage = lazy(() => import("./pages/VaultPage"));
const StatusPage = lazy(() => import("./pages/StatusPage"));
const SupportPage = lazy(() => import("./pages/SupportPage"));
import { OnboardingTour } from "./components/OnboardingTour";
import { ShortcutsHelp } from "./components/ShortcutsHelp";
const FirstSuccessListener = lazy(() => import("./components/FirstSuccessListener").then(m => ({ default: m.FirstSuccessListener })));
const RouteLoader = () => (
<div className="min-h-[40vh] animate-pulse px-4 py-10 sm:px-6">
<div className="mx-auto max-w-7xl space-y-4">
<div className="h-6 w-52 rounded-md bg-secondary/70" />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-28 rounded-xl border border-border bg-card/70" />
))}
</div>
</div>
</div>
);
const withRouteFallback = (element: JSX.Element) => (
<Suspense fallback={<RouteLoader />}>{element}</Suspense>
);
/**
* Warm the next-likely route chunks once the user lands on the home page.
* Top-3 by traffic: Compress, Merge, Split. Runs only on `/` and only when
* the browser is idle so it doesn't compete with first-paint resources.
*
* The actual `import()` calls are deduped inside prefetch.ts (WeakSet), so
* subsequent hover/focus events on these tools resolve from the chunk
* cache instead of issuing a new request.
*/
function RoutePrefetcher() {
const { pathname } = useLocation();
useEffect(() => {
if (pathname !== "/") return;
const idle: typeof window.requestIdleCallback | undefined =
typeof window !== "undefined" ? window.requestIdleCallback : undefined;
const run = () => {
// ToolPage shell first — needed before any /tool/* chunk renders.
prefetchRoute(loadToolPage);
prefetchRoute(loadCompressUI);
prefetchRoute(loadMergeUI);
prefetchRoute(loadSplitUI);
};
if (idle) {
const id = idle(run, { timeout: 3000 });
return () => window.cancelIdleCallback?.(id);
}
// Browsers without rIC — fire after a short delay.
const t = window.setTimeout(run, 1500);
return () => window.clearTimeout(t);
}, [pathname]);
return null;
}
function AfterInitialPaint({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
useEffect(() => {
if (ready) return;
// Lighthouse's mobile trace can run idle callbacks before its LCP window
// closes. Use a plain timer so nonessential chrome never competes with
// first paint or the first route's content.
const id = window.setTimeout(() => setReady(true), 3000);
return () => window.clearTimeout(id);
}, [ready]);
return ready ? <>{children}</> : null;
}
/** Wire global JS error handler into the root once. Lives inside the
* router so it can use hooks, but renders nothing. */
function GlobalErrorWire() {
useGlobalErrorHandler();
const { pathname } = useLocation();
// Counts the arrival, then every React Router navigation. The router moves
// with pushState, which fires no popstate, so the beacon has to be told.
useEffect(() => startPageviewTracking(), []);
useEffect(() => { notifyNavigation(); }, [pathname]);
return null;
}
const App = () => (
<ErrorBoundary scope="app">
<Sonner />
{/* AppProviders must wrap the router: Radix Tooltip throws at render time
without a TooltipProvider above it, which crashed every tool that used
one (compress, bates) straight into the ErrorBoundary. */}
<AppProviders>
<BrowserRouter>
<GlobalErrorWire />
{/* Navigation can request these as soon as the first screen is usable. */}
<ShortcutsHelp />
<OnboardingTour />
<AfterInitialPaint>
<Suspense fallback={null}>
<FirstSuccessListener />
</Suspense>
</AfterInitialPaint>
<RoutePrefetcher />
<AppShell>
<Routes>
<Route path="/" element={withRouteFallback(<Index />)} />
<Route path="/about" element={withRouteFallback(<AboutPage />)} />
<Route path="/compare" element={withRouteFallback(<ComparePage />)} />
<Route path="/compare/:competitor" element={withRouteFallback(<ComparePage />)} />
<Route path="/tool/:slug" element={withRouteFallback(<ToolPage />)} />
<Route path="/tools" element={withRouteFallback(<AllToolsPage />)} />
<Route path="/tools/:slug" element={withRouteFallback(<NonPdfToolPage />)} />
<Route path="/batch" element={withRouteFallback(<BatchPage />)} />
<Route path="/pipeline" element={withRouteFallback(<PipelinePage />)} />
<Route path="/blog" element={withRouteFallback(<BlogPage />)} />
<Route path="/blog/:slug" element={withRouteFallback(<BlogPostPage />)} />
<Route path="/privacy" element={withRouteFallback(<PrivacyPage />)} />
<Route path="/security" element={withRouteFallback(<SecurityPage />)} />
<Route path="/terms" element={withRouteFallback(<TermsPage />)} />
<Route path="/my-stuff" element={withRouteFallback(<MyStuffPage />)} />
<Route path="/my-stuff/vault" element={withRouteFallback(<VaultPage />)} />
<Route path="/account" element={withRouteFallback(<AccountPage />)} />
<Route path="/account/keys" element={withRouteFallback(<AccountPage />)} />
{/* SkinAppHost owns these page compositions, as it owns every
existing route above. Keep their public paths declared here
for the frontend/backend route-parity checks. */}
<Route path="/account/sign-in" element={null} />
<Route path="/account/sign-up" element={null} />
<Route path="/account/settings" element={null} />
<Route path="/settings" element={null} />
<Route path="/ai" element={null} />
<Route path="/api" element={null} />
<Route path="/trust" element={null} />
<Route path="/status" element={withRouteFallback(<StatusPage />)} />
<Route path="/support" element={withRouteFallback(<SupportPage />)} />
<Route path="*" element={withRouteFallback(<NotFound />)} />
</Routes>
</AppShell>
</BrowserRouter>
</AppProviders>
</ErrorBoundary>
);
export default App;