Fix Vite single-chunk bundles that cause slow page loads and break MCP Playwright testing. Auto-detects heavy dependencies, converts static page imports to React.lazy, adds Suspense boundaries in layout components, and configures vendor chunk splitting in vite.config.ts. Use when a Vite + React SPA ships everything in one JS chunk, when MCP Playwright times out on navigation or snapshots, when build output shows a single chunk over 500KB, or when pages load libraries they don't use. Also applies to Vue/Svelte projects with Vite — adapt the lazy-loading syntax to the framework's equivalent (defineAsyncComponent, dynamic import).
Fix single-chunk Vite builds that force browsers to parse the entire application on every page load. This causes slow initial renders and makes MCP Playwright unreliable (timeouts on navigate, stale snapshots, failed interactions).
browser_navigate or browser_snapshotvite build warns about chunk size but no manualChunks is configuredBefore making changes, measure the current state:
# Check current chunk distribution
ls -lhS <project>/dist/assets/*.js
# Look for the single-chunk pattern:
# - One file over 500KB (the monolith)
# - Maybe one framework chunk (react-vendor or similar)
# - Everything else is tiny or absent
If the largest chunk contains both application code AND vendor libraries, this skill applies.
Find the file that defines routes and imports all page components. Common locations:
src/main.tsx or src/App.tsx (React Router)src/router.ts or src/router/index.ts (Vue Router)src/routes/+layout.ts (SvelteKit — already lazy by default)Read the file. Count the static page imports. Each one pulls its entire dependency tree into the main chunk.
Check package.json for known-heavy libraries:
| Library | Typical Size | Only Needed On |
|---|---|---|
monaco-editor | 2-4MB | Code editor pages |
react-syntax-highlighter + prism | ~650KB | Code display pages |
@codemirror/* | ~400KB | Code editor pages |
@mui/material or antd | 200-500KB | Depends on usage |
recharts / chart.js + d3 | 50-200KB | Dashboard/analytics |
react-diff-viewer | ~180KB | Diff/compare pages |
@xyflow/react + dagre | ~90KB | Graph/flow pages |
marked / markdown-it + DOMPurify | ~60KB | Markdown preview pages |
react-image-crop | ~15KB | Image editor pages |
Map each heavy dependency to the page(s) that actually use it.
Replace every page import in the router file with React.lazy:
// Before — entire dep tree lands in main chunk
import { MyPage } from './pages/MyPage'
// After — separate chunk loaded on navigation
const MyPage = lazy(() =>
import('./pages/MyPage').then((m) => ({ default: m.MyPage }))
)
If the page uses export default:
const MyPage = lazy(() => import('./pages/MyPage'))
Keep layout components static. Any component that renders <Outlet /> (React Router), <RouterView /> (Vue Router), or <slot /> (Svelte) must stay as a static import so the shell never flashes.
const MyPage = defineAsyncComponent(() => import('./pages/MyPage.vue'))
SvelteKit already lazy-loads routes by default. No changes needed unless you have eagerly-imported heavy components inside pages.
Wrap <Outlet /> in each layout component with <Suspense>:
import { Suspense } from 'react'
// In the layout component:
<Suspense fallback={<div className="loading">Loading…</div>}>
<Outlet />
</Suspense>
Place Suspense inside layouts, not at the router root. The sidebar, header, and navigation tabs must stay visible while page chunks load.
If the project has nested layouts (e.g., a dashboard layout inside the app layout), add Suspense to each one that wraps an <Outlet />.
<Suspense>
<RouterView />
<template #fallback>
<div class="loading">Loading…</div>
</template>
</Suspense>
Add manualChunks to vite.config.ts: