Skip to main content
Glama
performance.ts21.1 kB
/** * @fileOverview: Performance analyzer for React applications * @module: PerformanceAnalyzer * @keyFunctions: * - analyzePerformance(): Analyze performance issues and optimization opportunities * - buildPerRouteAnalysis(): Build client-only import graphs per route * - detectHeavyImports(): Identify heavy client-side imports * - detectDynamicImportCandidates(): Find imports that should use dynamic loading * - detectMissingSuspenseBoundaries(): Identify routes missing loading/error boundaries * @context: Detects performance bottlenecks, heavy imports, and missing optimizations */ import { readFile } from 'fs/promises'; import * as path from 'path'; import type { FileInfo } from '../../../../core/compactor/fileDiscovery'; import { logger } from '../../../../utils/logger'; import type { ComponentInfo } from './components'; import { toPosixPath } from './router'; import { buildModuleGraph } from './graph'; export interface PerformanceIssue { file: string; import: string; sizeHint?: string; recommendation: string; severity: 'high' | 'medium' | 'low'; } export interface DependencyWeight { name: string; sizeKB: number; category: string; } export interface RoutePerformance { path: string; totalSizeKB: number; clientSizeKB: number; topDeps: Array<{ name: string; sizeKB: number; category: string; usedIn: string[]; }>; splitCandidates: Array<{ component: string; heavyDeps: string[]; recommendation: string; potentialSavingsKB: number; }>; clientComponents: string[]; serverComponents: string[]; } export interface PerformanceAnalysis { heavyClientImports: PerformanceIssue[]; noDynamicImportCandidates: string[]; missingSuspenseBoundaries: string[]; imagesWithoutNextImage: Array<{ file: string; line: number; element: string }>; perRouteAnalysis: RoutePerformance[]; dependencyWeights: Map<string, DependencyWeight>; } /** * Load dependency weights from weights.json */ async function loadDependencyWeights(): Promise<Map<string, DependencyWeight>> { try { const weightsPath = path.join(__dirname, 'weights.json'); const weightsContent = await readFile(weightsPath, 'utf-8'); const weightsData = JSON.parse(weightsContent); const weights = new Map<string, DependencyWeight>(); // Load individual dependencies for (const [name, sizeKB] of Object.entries(weightsData.dependencies)) { weights.set(name, { name, sizeKB: sizeKB as number, category: 'other', }); } // Assign categories for (const [category, patterns] of Object.entries(weightsData.categories)) { for (const pattern of patterns as string[]) { for (const [depName, weight] of weights) { if (depName.includes(pattern.replace('/', '').replace('*', ''))) { weight.category = category; } } } } return weights; } catch (error) { logger.warn('Failed to load dependency weights, using defaults', { error }); const defaults: Array<[string, number, string]> = [ ['@radix-ui/react-select', 45, 'radix'], ['recharts', 200, 'charts'], ['date-fns', 80, 'date-libraries'], ['lodash', 70, 'utility-libraries'], ['monaco-editor', 7000, 'editors'], ['three', 600, '3d'], ]; const map = new Map<string, DependencyWeight>(); defaults.forEach(([name, sizeKB, category]) => map.set(name, { name, sizeKB, category })); return map; } } /** * Extract imports from a file */ async function extractImports(filePath: string): Promise<string[]> { try { const content = await readFile(filePath, 'utf-8'); const imports: string[] = []; // Match ES6 imports const importRegex = /import\s+.*?from\s+['"]([^'"]+)['"]/g; let match; while ((match = importRegex.exec(content)) !== null) { imports.push(match[1]); } // Match dynamic imports const dynamicImportRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g; while ((match = dynamicImportRegex.exec(content)) !== null) { imports.push(match[1]); } // Match require statements const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g; while ((match = requireRegex.exec(content)) !== null) { imports.push(match[1]); } return [...new Set(imports)]; // Remove duplicates } catch (error) { logger.debug('Failed to extract imports from file', { filePath, error: error instanceof Error ? error.message : String(error), }); return []; } } /** * Build per-route performance analysis */ async function buildPerRouteAnalysis( files: FileInfo[], components: ComponentInfo[], dependencyWeights: Map<string, DependencyWeight> ): Promise<RoutePerformance[]> { const routeAnalysis: RoutePerformance[] = []; // Group files by route const routeGroups = new Map<string, FileInfo[]>(); // Group strictly by page files under app/**/page.(t|j)sx for (const file of files) { const rel = file.relPath.replace(/\\/g, '/'); if (!/(\.tsx|\.jsx)$/.test(rel)) continue; const isPage = /(^|\/)app\/.+\/page\.(tsx|jsx|ts|js)$/.test(rel); if (!isPage) continue; // Build the route path from directory structure, stripping groups and parallel slots const routeDir = rel.replace(/^.*?app\//, '').replace(/\/page\.(tsx|jsx|ts|js)$/, ''); const segments = routeDir .split('/') .filter(Boolean) .filter(seg => !/^\(.*\)$/.test(seg) && !seg.startsWith('@')); const route = '/' + segments.join('/'); const existingGroup = routeGroups.get(route); if (existingGroup) { existingGroup.push(file); } else { routeGroups.set(route, [file]); } } // Build shared module graph for accurate traversal const { imports, reverse } = await buildModuleGraph(files, process.cwd()); // Compute client classification via propagation const hasUseClient = new Set<string>(); for (const file of files) { if (!file.relPath.endsWith('.tsx') && !file.relPath.endsWith('.jsx')) continue; try { const content = await readFile(file.absPath, 'utf-8'); if (content.includes("'use client'") || content.includes('"use client"')) { hasUseClient.add(file.relPath.replace(/\\/g, '/')); } } catch (error) { logger.debug('Failed to inspect file for client directive during performance analysis', { file: toPosixPath(file.relPath), error: error instanceof Error ? error.message : String(error), }); } } const clientSet = new Set<string>([ ...hasUseClient, ...components.filter(c => c.kind === 'client').map(c => c.file.replace(/\\/g, '/')), ]); let changed = true; while (changed) { changed = false; for (const [mod, importers] of reverse) { if (!clientSet.has(mod) && Array.from(importers).some(i => clientSet.has(i))) { clientSet.add(mod); changed = true; } } } // Analyze each route for (const [routePath, routeFiles] of routeGroups) { const routePerf: RoutePerformance = { path: routePath, totalSizeKB: 0, clientSizeKB: 0, topDeps: [], splitCandidates: [], clientComponents: [], serverComponents: [], }; const depUsage = new Map<string, { sizeKB: number; category: string; usedIn: Set<string> }>(); const componentDeps = new Map<string, string[]>(); // Analyze each file in the route // For each route, start at the page file and traverse only client nodes reachable from the page const pageFiles = routeFiles.filter(f => /(^|\/)app\/.+\/page\.(tsx|jsx|ts|js)$/.test(f.relPath.replace(/\\/g, '/')) ); for (const pageFile of pageFiles) { const queue: string[] = [pageFile.relPath]; const visited = new Set<string>(); while (queue.length) { const current = queue.shift(); if (!current) { continue; } const relPath = toPosixPath(current); if (visited.has(relPath)) continue; visited.add(relPath); const f = files.find(ff => ff.relPath.replace(/\\/g, '/') === relPath); if (!f) continue; const fRel = f.relPath.replace(/\\/g, '/'); const isClientComponent = clientSet.has(fRel); if (isClientComponent) { if (!routePerf.clientComponents.includes(fRel)) routePerf.clientComponents.push(fRel); } else { if (!routePerf.serverComponents.includes(fRel)) routePerf.serverComponents.push(fRel); } // Extract imports for dependency attribution const impList = await extractImports(f.absPath); componentDeps.set(fRel, impList); for (const importPath of impList) { for (const [depName, weight] of dependencyWeights) { if (importPath === depName || importPath.startsWith(`${depName}/`)) { let usageEntry = depUsage.get(depName); if (!usageEntry) { usageEntry = { sizeKB: weight.sizeKB, category: weight.category, usedIn: new Set<string>(), }; depUsage.set(depName, usageEntry); } usageEntry.usedIn.add(fRel); break; } } } // Follow only local module imports from the graph const next = imports.get(relPath); if (next) { for (const child of next) { // Enqueue regardless; classification will decide if it counts toward client or server if (!visited.has(child)) queue.push(child); } } } } // Calculate sizes let totalSize = 0; let clientSize = 0; for (const usage of depUsage.values()) { totalSize += usage.sizeKB; // Check if used in client components const usedInClient = Array.from(usage.usedIn).some(file => routePerf.clientComponents.includes(file) ); if (usedInClient) { clientSize += usage.sizeKB; } } routePerf.totalSizeKB = totalSize; routePerf.clientSizeKB = clientSize; // Find top dependencies (by size, limit to top 10) routePerf.topDeps = Array.from(depUsage.entries()) .sort((a, b) => b[1].sizeKB - a[1].sizeKB) .slice(0, 10) .map(([name, usage]) => ({ name, sizeKB: usage.sizeKB, category: usage.category, usedIn: Array.from(usage.usedIn), })); // Find split candidates (components with heavy deps that could be lazy loaded) for (const [component, deps] of componentDeps) { const heavyDeps = deps.filter(dep => { for (const [depName, weight] of dependencyWeights) { if ((dep === depName || dep.startsWith(depName + '/')) && weight.sizeKB > 100) { return true; } } return false; }); if (heavyDeps.length > 0) { const potentialSavings = heavyDeps.reduce((sum, dep) => { for (const [depName, weight] of dependencyWeights) { if (dep === depName || dep.startsWith(depName + '/')) { return sum + weight.sizeKB; } } return sum; }, 0); if (potentialSavings > 50) { // Only suggest if savings > 50KB routePerf.splitCandidates.push({ component, heavyDeps, recommendation: `Use next/dynamic with ssr: false for ${component}`, potentialSavingsKB: potentialSavings, }); } } } routeAnalysis.push(routePerf); } return routeAnalysis.sort((a, b) => b.totalSizeKB - a.totalSizeKB); } /** * Heavy libraries that should be dynamically imported */ const HEAVY_LIBRARIES = [ 'monaco-editor', 'three', 'chart.js', 'd3', 'xlsx', 'pdfjs-dist', 'highlight.js', '@codemirror/', 'react-ace', 'recharts', 'echarts', 'react-pdf', 'react-table', 'ag-grid', 'react-virtualized', 'react-window', 'framer-motion', 'react-spring', 'react-transition-group', 'react-beautiful-dnd', 'react-dnd', 'react-color', 'react-datepicker', 'react-select', 'react-draft-wysiwyg', 'leaflet', 'mapbox-gl', 'react-leaflet', 'react-map-gl', 'video.js', 'plyr', 'hls.js', 'react-player', 'swiper', 'react-slick', 'react-responsive-carousel', ]; /** * Detect heavy imports in client components */ async function detectHeavyImports( files: FileInfo[], components: ComponentInfo[] ): Promise<PerformanceIssue[]> { const issues: PerformanceIssue[] = []; for (const file of files) { // Only check client components and files const isClientComponent = components.some( comp => comp.file === file.relPath && comp.kind === 'client' ); const hasUseClient = file.relPath.endsWith('.tsx') || file.relPath.endsWith('.jsx'); if (!isClientComponent && !hasUseClient) continue; try { const content = await readFile(file.absPath, 'utf-8'); // Check for heavy library imports for (const heavyLib of HEAVY_LIBRARIES) { const importRegex = new RegExp(`import.*from\\s+['"]([^'"]*${heavyLib}[^'"]*)['"]`, 'g'); const dynamicImportRegex = new RegExp( `import\\s*\\(\\s*['"]([^'"]*${heavyLib}[^'"]*)['"]`, 'g' ); let match; while ((match = importRegex.exec(content)) !== null) { const importPath = match[1]; // Check if it's already using dynamic import const hasDynamicImport = dynamicImportRegex.test(content); if (!hasDynamicImport) { issues.push({ file: toPosixPath(file.relPath), import: importPath, sizeHint: getLibrarySizeHint(heavyLib), recommendation: `Use next/dynamic with ssr: false for ${heavyLib}`, severity: 'high', }); } } } // Check for large bundle imports const largeBundlePatterns = [ { pattern: /import.*from\s+['"]([^'"]*lodash[^'"]*)['"]/g, hint: '~70KB' }, { pattern: /import.*from\s+['"]([^'"]*moment[^'"]*)['"]/g, hint: '~250KB' }, { pattern: /import.*from\s+['"]([^'"]*jquery[^'"]*)['"]/g, hint: '~30KB' }, { pattern: /import.*from\s+['"]([^'"]*bootstrap[^'"]*)['"]/g, hint: '~150KB' }, ]; for (const { pattern, hint } of largeBundlePatterns) { let match; while ((match = pattern.exec(content)) !== null) { const importPath = match[1]; issues.push({ file: toPosixPath(file.relPath), import: importPath, sizeHint: hint, recommendation: `Consider lazy loading or tree-shaking for ${importPath}`, severity: 'medium', }); } } } catch (error) { logger.debug('Failed to analyze heavy imports for file', { file: toPosixPath(file.relPath), error: error instanceof Error ? error.message : String(error), }); } } return issues; } /** * Get size hint for known heavy libraries */ function getLibrarySizeHint(library: string): string { const sizeMap: Record<string, string> = { 'monaco-editor': '~7MB', three: '~600KB', 'chart.js': '~200KB', d3: '~250KB', xlsx: '~150KB', 'pdfjs-dist': '~1.5MB', 'highlight.js': '~50KB', '@codemirror/': '~300KB', 'react-ace': '~150KB', recharts: '~200KB', echarts: '~800KB', leaflet: '~150KB', 'mapbox-gl': '~800KB', 'react-leaflet': '~50KB', 'react-map-gl': '~300KB', 'video.js': '~200KB', plyr: '~100KB', 'hls.js': '~150KB', 'react-player': '~100KB', swiper: '~150KB', 'react-slick': '~50KB', 'react-responsive-carousel': '~30KB', }; for (const [key, size] of Object.entries(sizeMap)) { if (library.includes(key.replace('/', ''))) { return size; } } return '~100KB+'; } /** * Detect images not using Next.js Image component */ async function detectImagesWithoutNextImage( files: FileInfo[] ): Promise<Array<{ file: string; line: number; element: string }>> { const issues: Array<{ file: string; line: number; element: string }> = []; for (const file of files) { if (!file.relPath.endsWith('.tsx') && !file.relPath.endsWith('.jsx')) continue; try { const content = await readFile(file.absPath, 'utf-8'); const lines = content.split('\n'); lines.forEach((line, index) => { const imgRegex = /<img\s+[^>]*>/g; let match; while ((match = imgRegex.exec(line)) !== null) { if (!line.includes('<Image') && !line.includes('next/image')) { issues.push({ file: toPosixPath(file.relPath), line: index + 1, element: match[0], }); } } }); } catch (error) { logger.debug('Failed to analyze image usage for file', { file: toPosixPath(file.relPath), error: error instanceof Error ? error.message : String(error), }); } } return issues; } /** * Detect routes missing suspense boundaries */ async function detectMissingSuspenseBoundaries(files: FileInfo[]): Promise<string[]> { const routeDirs: Set<string> = new Set(); const routesWithDataFetching: Set<string> = new Set(); const routesWithBoundaries: Set<string> = new Set(); // Find all page route directories (exclude app/api/**) for (const file of files) { const rel = file.relPath.replace(/\\/g, '/'); if (rel.includes('/page.') && !rel.includes('/app/api/')) { const routeDir = path.dirname(file.relPath); routeDirs.add(routeDir); } } // Check for data fetching patterns for (const file of files) { if (!file.relPath.endsWith('.tsx') && !file.relPath.endsWith('.jsx')) continue; try { const content = await readFile(file.absPath, 'utf-8'); const routeDir = path.dirname(file.relPath); if (routeDirs.has(routeDir)) { const hasDataFetching = /useQuery|useSWR|fetch\(|axios\./.test(content); if (hasDataFetching) { routesWithDataFetching.add(routeDir); } } } catch (error) { logger.debug('Failed to inspect suspense boundaries for route file', { file: toPosixPath(file.relPath), error: error instanceof Error ? error.message : String(error), }); } } // Check for loading/error boundaries (exclude app/api/**) for (const file of files) { const rel = file.relPath.replace(/\\/g, '/'); const fileName = path.basename(rel); if ((fileName === 'loading.tsx' || fileName === 'error.tsx') && !rel.includes('/app/api/')) { const routeDir = path.dirname(rel); routesWithBoundaries.add(routeDir); } } // Find routes with data fetching but no boundaries const missingBoundaries: string[] = []; for (const route of routesWithDataFetching) { if (!routesWithBoundaries.has(route)) { missingBoundaries.push(route); } } return missingBoundaries; } /** * Analyze performance issues in files */ export async function analyzePerformance( files: FileInfo[], components: ComponentInfo[] ): Promise<PerformanceAnalysis> { logger.info(`⚡ Analyzing performance issues in ${files.length} files`); // Load dependency weights const dependencyWeights = await loadDependencyWeights(); // Run all performance checks in parallel const [heavyClientImports, imagesWithoutNextImage, missingSuspenseBoundaries, perRouteAnalysis] = await Promise.all([ detectHeavyImports(files, components), detectImagesWithoutNextImage(files), detectMissingSuspenseBoundaries(files), buildPerRouteAnalysis(files, components, dependencyWeights), ]); // Find dynamic import candidates (files with heavy imports but no dynamic loading) const noDynamicImportCandidates: string[] = []; const filesWithHeavyImports = new Set(heavyClientImports.map(issue => issue.file)); for (const file of filesWithHeavyImports) { try { const fileInfo = files.find(f => f.relPath === file); if (fileInfo) { const content = await readFile(fileInfo.absPath, 'utf-8'); const hasDynamicImport = /import\s*\(/.test(content); if (!hasDynamicImport) { noDynamicImportCandidates.push(file); } } } catch (error) { logger.debug('Failed to determine dynamic import usage for file', { file, error: error instanceof Error ? error.message : String(error), }); } } const analysis: PerformanceAnalysis = { heavyClientImports, noDynamicImportCandidates, missingSuspenseBoundaries, imagesWithoutNextImage, perRouteAnalysis, dependencyWeights, }; logger.info( `⚡ Performance analysis complete: ${heavyClientImports.length} heavy imports, ${missingSuspenseBoundaries.length} missing boundaries, ${imagesWithoutNextImage.length} non-optimized images, ${perRouteAnalysis.length} routes analyzed` ); return analysis; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sbarron/AmbianceMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server