Files
metabuilder/tools/analysis/code/analyze-render-performance.ts

295 lines
8.0 KiB
TypeScript

#!/usr/bin/env tsx
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { basename, extname, join, relative } from 'path'
interface HookCounts {
[key: string]: number
}
interface ComponentMetrics {
file: string
component: string
lines: number
bytes: number
hooks: {
builtIn: number
custom: number
total: number
byHook: HookCounts
}
effects: number
memoization: number
estimatedRenderTimeMs: number
reasons: string[]
risk: 'low' | 'medium' | 'high'
}
const BUILTIN_HOOKS = [
'useState',
'useReducer',
'useEffect',
'useLayoutEffect',
'useInsertionEffect',
'useMemo',
'useCallback',
'useRef',
'useContext',
'useSyncExternalStore',
'useTransition',
'useDeferredValue',
'useId',
'useImperativeHandle',
]
const BUILTIN_HOOK_SET = new Set(BUILTIN_HOOKS)
const SKIP_DIRS = new Set([
'node_modules',
'.next',
'dist',
'build',
'coverage',
'.git',
'__tests__',
'__mocks__',
'__snapshots__',
])
const THRESHOLDS = {
slowRenderMs: 16,
largeComponentLines: 200,
veryLargeComponentLines: 300,
highHookCount: 12,
highEffectCount: 3,
}
const TARGET_EXTENSIONS = new Set(['.tsx'])
function countMatches(content: string, regex: RegExp): number {
return content.match(regex)?.length ?? 0
}
function pickSourceRoot(): string | null {
const candidates = [
process.env.RENDER_ANALYSIS_ROOT,
join(process.cwd(), 'frontends', 'nextjs', 'src'),
join(process.cwd(), 'src'),
].filter(Boolean) as string[]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return null
}
function walkDir(dir: string, files: string[]): void {
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
return
}
for (const entry of entries) {
const fullPath = join(dir, entry)
let stats
try {
stats = statSync(fullPath)
} catch {
continue
}
if (stats.isDirectory()) {
if (SKIP_DIRS.has(entry)) {
continue
}
walkDir(fullPath, files)
continue
}
if (!stats.isFile()) {
continue
}
if (!TARGET_EXTENSIONS.has(extname(entry))) {
continue
}
if (entry.endsWith('.test.tsx') || entry.endsWith('.spec.tsx') || entry.endsWith('.stories.tsx')) {
continue
}
files.push(fullPath)
}
}
function estimateRenderTimeMs(lines: number, hooks: number, effects: number, memoization: number): number {
const base = 1.5
const lineCost = Math.min(lines, 400) * 0.03
const hookCost = hooks * 0.4
const effectCost = effects * 0.8
const memoSavings = Math.min(memoization, 4) * 0.3
const estimate = base + lineCost + hookCost + effectCost - memoSavings
return Math.max(0.5, Math.round(estimate * 10) / 10)
}
function analyzeFile(filePath: string): ComponentMetrics | null {
let content = ''
try {
content = readFileSync(filePath, 'utf8')
} catch {
return null
}
const lines = content.split(/\r?\n/).length
const bytes = Buffer.byteLength(content, 'utf8')
const byHook: HookCounts = {}
let builtInCount = 0
for (const hook of BUILTIN_HOOKS) {
const count = countMatches(content, new RegExp(`\\b${hook}\\b`, 'g'))
byHook[hook] = count
builtInCount += count
}
const allHookCalls = content.match(/\buse[A-Z]\w*\b/g) ?? []
const customHookCount = Math.max(0, allHookCalls.filter(hook => !BUILTIN_HOOK_SET.has(hook)).length)
const hookCount = builtInCount + customHookCount
const effectCount = (byHook.useEffect ?? 0) + (byHook.useLayoutEffect ?? 0) + (byHook.useInsertionEffect ?? 0)
const memoCount = (byHook.useMemo ?? 0) + (byHook.useCallback ?? 0)
const reactMemoCount = countMatches(content, /\bReact\.memo\b/g)
const memoCallCount = countMatches(content, /\bmemo\s*\(/g)
const memoization = memoCount + reactMemoCount + Math.max(0, memoCallCount - reactMemoCount)
const estimatedRenderTimeMs = estimateRenderTimeMs(lines, hookCount, effectCount, memoization)
const reasons: string[] = []
if (lines >= THRESHOLDS.veryLargeComponentLines) {
reasons.push(`Very large component: ${lines} lines`)
} else if (lines >= THRESHOLDS.largeComponentLines) {
reasons.push(`Large component: ${lines} lines`)
}
if (hookCount >= THRESHOLDS.highHookCount) {
reasons.push(`High hook count: ${hookCount}`)
}
if (effectCount >= THRESHOLDS.highEffectCount) {
reasons.push(`Multiple effects: ${effectCount}`)
}
if (estimatedRenderTimeMs >= THRESHOLDS.slowRenderMs) {
reasons.push(`Estimated render time: ${estimatedRenderTimeMs}ms`)
}
let risk: ComponentMetrics['risk'] = 'low'
if (reasons.length >= 3 || estimatedRenderTimeMs >= THRESHOLDS.slowRenderMs) {
risk = 'high'
} else if (reasons.length >= 1) {
risk = 'medium'
}
return {
file: relative(process.cwd(), filePath),
component: basename(filePath, '.tsx'),
lines,
bytes,
hooks: {
builtIn: builtInCount,
custom: customHookCount,
total: hookCount,
byHook,
},
effects: effectCount,
memoization,
estimatedRenderTimeMs,
reasons,
risk,
}
}
function buildRecommendations(slowComponents: ComponentMetrics[]): string[] {
const recommendations: string[] = []
if (slowComponents.length === 0) {
recommendations.push('No high-risk components detected. Re-run after significant UI changes.')
return recommendations
}
if (slowComponents.some(component => component.lines >= THRESHOLDS.veryLargeComponentLines)) {
recommendations.push('Split components over 300 lines into smaller pieces to reduce render work.')
}
if (slowComponents.some(component => component.effects >= THRESHOLDS.highEffectCount)) {
recommendations.push('Reduce the number of effects per component by extracting side effects into hooks.')
}
if (slowComponents.some(component => component.hooks.total >= THRESHOLDS.highHookCount)) {
recommendations.push('Consider splitting stateful logic across smaller components or hooks.')
}
if (slowComponents.some(component => component.memoization === 0 && component.estimatedRenderTimeMs >= THRESHOLDS.slowRenderMs)) {
recommendations.push('Add memoization (React.memo/useMemo/useCallback) where render work is heavy.')
}
if (recommendations.length === 0) {
recommendations.push('Review flagged components for unnecessary renders or expensive computations.')
}
return recommendations
}
const rootDir = pickSourceRoot()
if (!rootDir) {
console.log(JSON.stringify({
analysisType: 'static-heuristic',
averageRenderTime: 0,
slowComponents: [],
recommendations: ['No source directory found to analyze.'],
timestamp: new Date().toISOString(),
}, null, 2))
process.exit(0)
}
const files: string[] = []
walkDir(rootDir, files)
const metrics: ComponentMetrics[] = files
.map(file => analyzeFile(file))
.filter((result): result is ComponentMetrics => result !== null)
const averageRenderTime = metrics.length === 0
? 0
: Math.round((metrics.reduce((sum, metric) => sum + metric.estimatedRenderTimeMs, 0) / metrics.length) * 10) / 10
const slowComponents = metrics
.filter(metric => metric.reasons.length > 0 || metric.estimatedRenderTimeMs >= THRESHOLDS.slowRenderMs)
.sort((a, b) => b.estimatedRenderTimeMs - a.estimatedRenderTimeMs)
const topByLines = [...metrics].sort((a, b) => b.lines - a.lines).slice(0, 10)
const topByHooks = [...metrics].sort((a, b) => b.hooks.total - a.hooks.total).slice(0, 10)
const summary = {
analysisType: 'static-heuristic',
rootDir: relative(process.cwd(), rootDir) || '.',
componentsAnalyzed: metrics.length,
averageRenderTime,
averageRenderTimeMs: averageRenderTime,
slowComponentsTotal: slowComponents.length,
thresholds: THRESHOLDS,
slowComponents: slowComponents.slice(0, 15),
topByLines,
topByHooks,
recommendations: buildRecommendations(slowComponents),
note: 'Estimated render times are derived from file size and hook usage. Use React Profiler for real timings.',
timestamp: new Date().toISOString(),
}
console.log(JSON.stringify(summary, null, 2))