diff --git a/apps/bundle-analyzer/app/page.tsx b/apps/bundle-analyzer/app/page.tsx index f57ce4c7c7aed7..99fca28a7b9296 100644 --- a/apps/bundle-analyzer/app/page.tsx +++ b/apps/bundle-analyzer/app/page.tsx @@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input' import { Skeleton, TreemapSkeleton } from '@/components/ui/skeleton' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { AnalyzeData, ModulesData } from '@/lib/analyze-data' +import { computeActiveEntries, computeModuleDepthMap } from '@/lib/module-graph' import { SpecialModule } from '@/lib/types' import { getSpecialModuleType, fetchStrict } from '@/lib/utils' @@ -29,7 +30,9 @@ function formatBytes(bytes: number): string { export default function Home() { const [selectedRoute, setSelectedRoute] = useState(null) - const [environmentFilter, setEnvironmentFilter] = useState('client') + const [environmentFilter, setEnvironmentFilter] = useState< + 'client' | 'server' + >('client') const [typeFilter, setTypeFilter] = useState(['js', 'css', 'json']) const [selectedSourceIndex, setSelectedSourceIndex] = useState( null @@ -100,6 +103,14 @@ export default function Home() { return () => window.removeEventListener('keydown', handleKeyDown) }, []) + // Compute module depth map from active entries + const moduleDepthMap = useMemo(() => { + if (!modulesData || !analyzeData) return new Map() + + const activeEntries = computeActiveEntries(modulesData, analyzeData) + return computeModuleDepthMap(modulesData, activeEntries) + }, [modulesData, analyzeData]) + const filterSource = useMemo(() => { if (!analyzeData) return undefined @@ -172,7 +183,7 @@ export default function Home() { className="mr-4" value={environmentFilter} onValueChange={(value) => { - if (value) setEnvironmentFilter(value) + if (value) setEnvironmentFilter(value as 'client' | 'server') }} size="sm" > @@ -332,7 +343,8 @@ export default function Home() { startFileId={selectedSourceIndex} analyzeData={analyzeData} modulesData={modulesData} - filterSource={filterSource} + depthMap={moduleDepthMap} + environmentFilter={environmentFilter} /> )} {(() => { diff --git a/apps/bundle-analyzer/components/import-chain.tsx b/apps/bundle-analyzer/components/import-chain.tsx index 74129fe5958871..7a7d820265776e 100644 --- a/apps/bundle-analyzer/components/import-chain.tsx +++ b/apps/bundle-analyzer/components/import-chain.tsx @@ -4,104 +4,141 @@ import { ArrowUp, ChevronLeft, ChevronRight, - Monitor, - Route, + Box, + File, + PanelTop, + SquareFunction, Server, + Globe, + MessageCircleQuestion, } from 'lucide-react' import { useMemo, useState } from 'react' -import type { AnalyzeData, ModulesData } from '@/lib/analyze-data' +import type { + AnalyzeData, + ModuleIndex, + ModulesData, + SourceIndex, +} from '@/lib/analyze-data' +import { splitIdent } from '@/lib/utils' +import clsx from 'clsx' interface ImportChainProps { startFileId: number analyzeData: AnalyzeData modulesData: ModulesData - filterSource?: (sourceIndex: number) => boolean + depthMap: Map + environmentFilter: 'client' | 'server' } interface ChainLevel { - fileId: number - filePath: string - fileDepth: number + moduleIndex: ModuleIndex + sourceIndex: SourceIndex | undefined + path: string + depth: number + fullPath?: string + templateArgs?: string + layer?: string + moduleType?: string + treeShaking?: string selectedIndex: number totalCount: number // Info about this level's relationship to parent (undefined for root) info?: DependentInfo - // All available dependents at this level - allDependents?: DependentInfo[] } interface DependentInfo { moduleIndex: number sourceIndex: number | undefined + ident: string isAsync: boolean depth: number - flags: - | { - client: boolean - server: boolean - traced: boolean - js: boolean - css: boolean - json: boolean - asset: boolean - } - | undefined } -function getPathDifference(currentPath: string, previousPath: string | null) { - if (!previousPath) { - return { common: '', different: currentPath } - } +type PathPart = { + segment: string + isCommon: boolean + isLastCommon: boolean + isPackageName: boolean + isInfrastructure: boolean +} - const currentSegments = currentPath.split('/') - const previousSegments = previousPath.split('/') +function spitPathSegments(path: string): string[] { + return Array.from(path.matchAll(/(.+?(?:\/|$))/g)).map(([i]) => i) +} + +function getPathParts( + currentPath: string, + previousPath: string | null +): PathPart[] { + const currentSegments = spitPathSegments(currentPath) let commonCount = 0 - const minLength = Math.min(currentSegments.length, previousSegments.length) + if (previousPath) { + const previousSegments = spitPathSegments(previousPath) - for (let i = 0; i < minLength; i++) { - if (currentSegments[i] === previousSegments[i]) { - commonCount++ - } else { - break + const minLength = Math.min(currentSegments.length, previousSegments.length) + + for (let i = 0; i < minLength; i++) { + if (currentSegments[i] === previousSegments[i]) { + commonCount++ + } else { + break + } } } - const commonSegments = currentSegments.slice(0, commonCount) - const differentSegments = currentSegments.slice(commonCount) - - return { - common: commonSegments.length > 0 ? `${commonSegments.join('/')}/` : '', - different: differentSegments.join('/'), + let infrastructureCount = 0 + let packageNameCount = 0 + let nodeModulesIndex = currentSegments.lastIndexOf('node_modules/') + if (nodeModulesIndex === -1) { + nodeModulesIndex = currentSegments.length + } else { + infrastructureCount = nodeModulesIndex + 1 + if (currentSegments[nodeModulesIndex + 1]?.startsWith('@')) { + packageNameCount = 2 + } else { + packageNameCount = 1 + } } + + return currentSegments.map((segment, i) => ({ + segment, + isCommon: i < commonCount, + isLastCommon: i === commonCount - 1, + isInfrastructure: i < infrastructureCount, + isPackageName: + i >= infrastructureCount && i < infrastructureCount + packageNameCount, + })) } -const insertLineBreaks = (path: string) => { - const segments = path.split('/') - return segments.map((segment, i) => ( - 20 ? 'break-all' : ''} - > - {segment} - {i < segments.length - 1 && '/'} - - - )) +function getTitle(level: ChainLevel) { + const parts = [] + if (level.fullPath) parts.push(`Full Path: ${level.fullPath}`) + else parts.push(`Path: ${level.path}`) + if (level.layer) parts.push(`Layer: ${level.layer}`) + if (level.moduleType) parts.push(`Module Type: ${level.moduleType}`) + if (level.treeShaking) parts.push(`Tree Shaking: ${level.treeShaking}`) + if (level.templateArgs) parts.push(`Template Args: ${level.templateArgs}`) + return parts.join('\n') } export function ImportChain({ startFileId, analyzeData, modulesData, + depthMap, + environmentFilter, }: ImportChainProps) { + // Filter to include only the current route + const [showAll, setShowAll] = useState(false) + // Track which dependent is selected at each level const [selectedIndices, setSelectedIndices] = useState([]) - // Helper function to get module index from source path - const getModuleIndexFromSourceIndex = (sourceIndex: number) => { + // Helper function to get module indices from source path + const getModuleIndicesFromSourceIndex = (sourceIndex: number) => { const path = analyzeData.getFullSourcePath(sourceIndex) - return modulesData.getModuleIndexFromPath(path) + return modulesData.getModuleIndiciesFromPath(path) } // Helper function to get source index from module path @@ -127,22 +164,50 @@ export function ImportChain({ const startPath = analyzeData.getFullSourcePath(startFileId) if (!startPath) return result - // Get the module index for the starting source - const startModuleIndex = getModuleIndexFromSourceIndex(startFileId) - if (startModuleIndex === undefined) return result + // Get all module indices for the starting source + const startModuleIndices = getModuleIndicesFromSourceIndex( + startFileId + ).filter((moduleIndex) => { + if (!showAll && !depthMap.has(moduleIndex)) { + return false + } + let module = modulesData.module(moduleIndex) + let layer = splitIdent(module?.ident || '').layer + if (layer) { + if (environmentFilter === 'client' && /ssr|rsc|route|api/.test(layer)) { + return false + } + if (environmentFilter === 'server' && /client/.test(layer)) { + return false + } + } + return true + }) + if (startModuleIndices.length === 0) return result + + // Get the selected index for the start modules (default to 0) + const selectedStartIdx = selectedIndices[0] ?? 0 + const actualStartIdx = Math.min( + selectedStartIdx, + startModuleIndices.length - 1 + ) + const startModuleIndex = startModuleIndices[actualStartIdx] + const startIdent = modulesData.module(startModuleIndex)?.ident ?? '' result.push({ - fileId: startFileId, - filePath: startPath, - fileDepth: modulesData.module(startModuleIndex)?.depth ?? Infinity, - selectedIndex: 0, - totalCount: 1, + moduleIndex: startModuleIndex, + sourceIndex: startFileId, + path: startPath, + ...splitIdent(startIdent), + depth: depthMap.get(startModuleIndex) ?? Infinity, + selectedIndex: actualStartIdx, + totalCount: startModuleIndices.length, }) visitedModules.add(startModuleIndex) // Build chain by following selected dependents - let levelIndex = 0 + let levelIndex = 1 let currentModuleIndex = startModuleIndex while (true) { @@ -150,15 +215,24 @@ export function ImportChain({ const dependentModuleIndices = [ ...modulesData .moduleDependents(currentModuleIndex) - .map((index: number) => ({ index, async: false })), + .map((index: number) => ({ + index, + async: false, + depth: depthMap.get(index) ?? Infinity, + })), ...modulesData .asyncModuleDependents(currentModuleIndex) - .map((index: number) => ({ index, async: true })), + .map((index: number) => ({ + index, + async: true, + depth: depthMap.get(index) ?? Infinity, + })), ] // Filter out dependents that would create a cycle const validDependents = dependentModuleIndices.filter( - ({ index }) => !visitedModules.has(index) + ({ index, depth }) => + !visitedModules.has(index) && (isFinite(depth) || showAll) ) if (validDependents.length === 0) { @@ -168,39 +242,31 @@ export function ImportChain({ // Build info for each dependent const dependentsInfo: DependentInfo[] = validDependents.map( - ({ index: moduleIndex, async: isAsync }) => { - const module = modulesData.module(moduleIndex) + ({ index: moduleIndex, async: isAsync, depth }) => { const sourceIndex = getSourceIndexFromModuleIndex(moduleIndex) - const flags = - sourceIndex !== undefined - ? analyzeData.getSourceFlags(sourceIndex) - : undefined + let ident = modulesData.module(moduleIndex)?.ident || '' return { moduleIndex, sourceIndex, + ident, isAsync, - depth: module?.depth ?? Infinity, - flags, + depth, } } ) // Sort: sync first, async second, then by source presence, then by depth dependentsInfo.sort((a, b) => { - // First sort by async state (sync before async) - if (a.isAsync !== b.isAsync) { - return a.isAsync ? 1 : -1 + // Sort by depth (smallest first) + if (a.depth !== b.depth) { + return a.depth - b.depth } - - // Then sort by source presence (with source before without) - const aHasSource = a.sourceIndex !== undefined - const bHasSource = b.sourceIndex !== undefined - if (aHasSource !== bHasSource) { - return aHasSource ? -1 : 1 + // Sort by ident length (shortest first) + if (a.ident.length !== b.ident.length) { + return a.ident.length - b.ident.length } - - // Finally sort by depth (smallest first) - return a.depth - b.depth + // Sort by ident + return a.ident.localeCompare(b.ident) }) // Get the selected index for this level (default to 0) @@ -213,13 +279,14 @@ export function ImportChain({ if (!selectedDepModule) break result.push({ - fileId: selectedDepInfo.moduleIndex, - filePath: selectedDepModule.path, - fileDepth: selectedDepModule.depth, + moduleIndex: selectedDepInfo.moduleIndex, + sourceIndex: selectedDepInfo.sourceIndex, + path: selectedDepModule.path, + depth: depthMap.get(selectedDepInfo.moduleIndex) ?? Infinity, + ...splitIdent(selectedDepModule.ident), selectedIndex: actualIdx, totalCount: dependentsInfo.length, info: selectedDepInfo, - allDependents: dependentsInfo, }) visitedModules.add(selectedDepInfo.moduleIndex) @@ -232,13 +299,21 @@ export function ImportChain({ return result // eslint-disable-next-line react-hooks/exhaustive-deps - }, [startFileId, analyzeData, modulesData, selectedIndices]) + }, [ + startFileId, + analyzeData, + modulesData, + selectedIndices, + showAll, + depthMap, + environmentFilter, + ]) const handlePrevious = (levelIndex: number) => { setSelectedIndices((prev) => { const newIndices = [...prev] const currentIdx = newIndices[levelIndex] ?? 0 - const level = chain[levelIndex + 1] // Fixed: use levelIndex + 1 to get the correct level + const level = chain[levelIndex] newIndices[levelIndex] = currentIdx > 0 ? currentIdx - 1 : level.totalCount - 1 return newIndices.slice(0, levelIndex + 1) @@ -249,7 +324,7 @@ export function ImportChain({ setSelectedIndices((prev) => { const newIndices = [...prev] const currentIdx = newIndices[levelIndex] ?? 0 - const level = chain[levelIndex + 1] // Fixed: use levelIndex + 1 to get the correct level + const level = chain[levelIndex] newIndices[levelIndex] = currentIdx < level.totalCount - 1 ? currentIdx + 1 : 0 return newIndices.slice(0, levelIndex + 1) @@ -264,103 +339,165 @@ export function ImportChain({

Import chain

{chain.map((level, index) => { - const previousPath = index > 0 ? chain[index - 1].filePath : null - const { common, different } = getPathDifference( - level.filePath, - previousPath - ) + const previousPath = index > 0 ? chain[index - 1].path : null + const parts = getPathParts(level.path, previousPath) + + const flags = + level.sourceIndex !== undefined + ? analyzeData.getSourceFlags(level.sourceIndex) + : undefined // Get the current item's info from the level itself const currentItemInfo = level.info return ( -
+
+ {currentItemInfo?.isAsync &&
} +
+ {currentItemInfo?.isAsync && ( + + (async) + + )} + {index > 0 ? ( + + ) : undefined} + {level.totalCount > 1 && ( +
+ + + {level.selectedIndex + 1}/{level.totalCount} + + +
+ )} +
+ {currentItemInfo?.isAsync &&
}
+
+ {!level.layer ? ( +
+ +
+ ) : /app/.test(level.layer || '') ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
- {index === 0 ? ( - - {insertLineBreaks(level.filePath)} - - ) : ( - <> - {common && ( - - {insertLineBreaks(common)} - - )} - - {insertLineBreaks(different)} + {parts.map( + ( + { + segment, + isCommon, + isLastCommon, + isInfrastructure, + isPackageName, + }, + i + ) => ( + 20 && 'break-all', + isCommon && + !isLastCommon && + !isPackageName && + !isInfrastructure && + 'text-muted-foreground/80', + !isCommon && !isInfrastructure && 'font-bold', + isInfrastructure && 'text-muted-foreground/50', + isPackageName && 'text-orange-500' + )} + > + {segment} + - + ) )}
{/* Show icons for current item if we have flag info or no source */} - {currentItemInfo && ( -
- {currentItemInfo.sourceIndex === undefined && ( -
- +
+ {/client/.test(level.layer || '') && + (flags?.client ? ( +
+
- )} - {currentItemInfo.flags?.client ? ( -
- + ) : ( +
+
- ) : currentItemInfo.flags?.server ? ( -
- + ))} + {/ssr/.test(level.layer || '') && + (flags?.server ? ( +
+
- ) : null} + ) : ( +
+ +
+ ))} + {/rsc/.test(level.layer || '') && + (flags?.server ? ( +
+ +
+ ) : ( +
+ +
+ ))} + {/route|api/.test(level.layer || '') && + (flags?.server ? ( +
+ +
+ ) : ( +
+ +
+ ))} +
+
+ +
+ {level.treeShaking && ( +
+ {level.treeShaking === 'locals' + ? '(local declarations only)' + : level.treeShaking === 'module evaluation' + ? '(only the module evaluation part of the module)' + : `(${level.treeShaking})`}
)}
- - {index < chain.length - 1 && - (() => { - // Get the info for the next item (the one the arrow points to) - const nextItemInfo = chain[index + 1].info - - return ( -
- - {chain[index + 1].totalCount > 1 && ( -
- - - {chain[index + 1].selectedIndex + 1}/ - {chain[index + 1].totalCount} - - -
- )} -
- ) - })()}
) })} @@ -370,6 +507,19 @@ export function ImportChain({

)}
+
+ +
) } diff --git a/apps/bundle-analyzer/lib/analyze-data.ts b/apps/bundle-analyzer/lib/analyze-data.ts index 1ae82a5aa17068..a780cc86bfcb9e 100644 --- a/apps/bundle-analyzer/lib/analyze-data.ts +++ b/apps/bundle-analyzer/lib/analyze-data.ts @@ -1,8 +1,12 @@ // Type definitions matching the Rust structures from analyze.rs +// Type aliases for better readability +export type ModuleIndex = number +export type SourceIndex = number + export interface AnalyzeModule { + ident: string path: string - depth: number } export interface AnalyzeSource { @@ -53,7 +57,7 @@ interface ModulesDataHeader { export class ModulesData { private modulesHeader: ModulesDataHeader private modulesBinaryData: DataView - private pathToModuleIndex: Map + private pathToModuleIndex: Map constructor(modulesArrayBuffer: ArrayBuffer) { // Parse modules.data @@ -76,11 +80,16 @@ export class ModulesData { this.pathToModuleIndex = new Map() for (let i = 0; i < this.modulesHeader.modules.length; i++) { const module = this.modulesHeader.modules[i] - this.pathToModuleIndex.set(module.path, i) + const existing = this.pathToModuleIndex.get(module.path) + if (existing) { + existing.push(i) + } else { + this.pathToModuleIndex.set(module.path, [i]) + } } } - module(index: number): AnalyzeModule | undefined { + module(index: ModuleIndex): AnalyzeModule | undefined { return this.modulesHeader.modules[index] } @@ -88,15 +97,15 @@ export class ModulesData { return this.modulesHeader.modules.length } - getModuleIndexFromPath(path: string): number | undefined { - return this.pathToModuleIndex.get(path) + getModuleIndiciesFromPath(path: string): ModuleIndex[] { + return this.pathToModuleIndex.get(path) ?? [] } // Read edges data for a specific index only private readEdgesDataAtIndex( reference: EdgesDataReference, - index: number - ): number[] { + index: ModuleIndex + ): ModuleIndex[] { const { offset, length } = reference if (length === 0) { @@ -143,28 +152,28 @@ export class ModulesData { return edges } - moduleDependents(index: number): number[] { + moduleDependents(index: ModuleIndex): ModuleIndex[] { return this.readEdgesDataAtIndex( this.modulesHeader.module_dependents, index ) } - asyncModuleDependents(index: number): number[] { + asyncModuleDependents(index: ModuleIndex): ModuleIndex[] { return this.readEdgesDataAtIndex( this.modulesHeader.async_module_dependents, index ) } - moduleDependencies(index: number): number[] { + moduleDependencies(index: ModuleIndex): ModuleIndex[] { return this.readEdgesDataAtIndex( this.modulesHeader.module_dependencies, index ) } - asyncModuleDependencies(index: number): number[] { + asyncModuleDependencies(index: ModuleIndex): ModuleIndex[] { return this.readEdgesDataAtIndex( this.modulesHeader.async_module_dependencies, index @@ -182,6 +191,7 @@ export class ModulesData { export class AnalyzeData { private analyzeHeader: AnalyzeDataHeader private analyzeBinaryData: DataView + private pathToSourceIndex: Map constructor(analyzeArrayBuffer: ArrayBuffer) { // Parse analyze.data @@ -199,11 +209,18 @@ export class AnalyzeData { analyzeArrayBuffer, analyzeBinaryOffset ) + + // Build pathToSourceIndex map + this.pathToSourceIndex = new Map() + for (let i = 0; i < this.analyzeHeader.sources.length; i++) { + const fullPath = this.getFullSourcePath(i) + this.pathToSourceIndex.set(fullPath, i) + } } // Accessor methods for header data - source(index: number): AnalyzeSource | undefined { + source(index: SourceIndex): AnalyzeSource | undefined { return this.analyzeHeader.sources[index] } @@ -211,6 +228,10 @@ export class AnalyzeData { return this.analyzeHeader.sources.length } + getSourceIndexFromPath(path: string): SourceIndex | undefined { + return this.pathToSourceIndex.get(path) + } + chunkPart(index: number): AnalyzeChunkPart | undefined { return this.analyzeHeader.chunk_parts[index] } @@ -227,7 +248,7 @@ export class AnalyzeData { return this.analyzeHeader.output_files.length } - sourceRoots(): number[] { + sourceRoots(): SourceIndex[] { return this.analyzeHeader.source_roots } @@ -236,8 +257,8 @@ export class AnalyzeData { // Read edges data for a specific index only private readEdgesDataAtIndex( reference: EdgesDataReference, - index: number - ): number[] { + index: SourceIndex + ): SourceIndex[] { const { offset, length } = reference if (length === 0) { @@ -291,19 +312,19 @@ export class AnalyzeData { ) } - sourceChunkParts(index: number): number[] { + sourceChunkParts(index: SourceIndex): number[] { return this.readEdgesDataAtIndex( this.analyzeHeader.source_chunk_parts, index ) } - sourceChildren(index: number): number[] { + sourceChildren(index: SourceIndex): SourceIndex[] { return this.readEdgesDataAtIndex(this.analyzeHeader.source_children, index) } // Utility method to get the full path of a source by walking up the parent chain - getFullSourcePath(index: number): string { + getFullSourcePath(index: SourceIndex): string { const source = this.source(index) if (!source) return '' @@ -315,7 +336,7 @@ export class AnalyzeData { return parentPath + source.path } - getSourceOutputSize(index: number): number { + getSourceOutputSize(index: SourceIndex): number { const chunkParts = this.sourceChunkParts(index) let totalSize = 0 for (const chunkPartIndex of chunkParts) { @@ -327,7 +348,7 @@ export class AnalyzeData { return totalSize } - sourceChunks(index: number): string[] { + sourceChunks(index: SourceIndex): string[] { const chunkParts = this.sourceChunkParts(index) const uniqueChunks = new Set() @@ -344,7 +365,7 @@ export class AnalyzeData { return Array.from(uniqueChunks).sort() } - getSourceFlags(index: number): { + getSourceFlags(index: SourceIndex): { client: boolean server: boolean traced: boolean @@ -388,14 +409,14 @@ export class AnalyzeData { return { client, server, traced, js, css, json, asset } } - isPolyfillModule(index: number): boolean { + isPolyfillModule(index: SourceIndex): boolean { const fullSourcePath = this.getFullSourcePath(index) return fullSourcePath.endsWith( 'node_modules/next/dist/build/polyfills/polyfill-module.js' ) } - isPolyfillNoModule(index: number): boolean { + isPolyfillNoModule(index: SourceIndex): boolean { const fullSourcePath = this.getFullSourcePath(index) return fullSourcePath.endsWith( 'node_modules/next/dist/build/polyfills/polyfill-nomodule.js' diff --git a/apps/bundle-analyzer/lib/module-graph.ts b/apps/bundle-analyzer/lib/module-graph.ts new file mode 100644 index 00000000000000..02c4652d4a901f --- /dev/null +++ b/apps/bundle-analyzer/lib/module-graph.ts @@ -0,0 +1,143 @@ +import type { AnalyzeData, ModuleIndex, ModulesData } from './analyze-data' + +/** + * Compute active entries from the current route's sources. + * + * It's a heuristic approach that looks for known entry module idents + * and traces their dependencies to find active modules. + * + * I don't like it as it has too much assumptions about next.js internals. + * It would be better if the source map contains idents instead of only paths. + */ +export function computeActiveEntries( + modulesData: ModulesData, + analyzeData: AnalyzeData +): ModuleIndex[] { + const potentialEntryDependents = [ + 'next/dist/esm/build/templates/pages.js', + 'next/dist/esm/build/templates/pages-api.js', + 'next/dist/esm/build/templates/pages-edge-api.js', + 'next/dist/esm/build/templates/edge-ssr.js', + 'next/dist/esm/build/templates/app-route.js', + 'next/dist/esm/build/templates/edge-app-route.js', + 'next/dist/esm/build/templates/app-page.js', + 'next/dist/esm/build/templates/edge-ssr-app.js', + 'next/dist/esm/build/templates/middleware.js', + '[next]/entry/page-loader.ts', + ] + const potentialEntries = [ + 'next/dist/client/app-next-turbopack.js', + 'next/dist/client/next-turbopack.js', + ] + + const activeEntries = new Set() + + for ( + let moduleIndex = 0; + moduleIndex < modulesData.moduleCount(); + moduleIndex++ + ) { + const ident = modulesData.module(moduleIndex)!.ident + + if ( + potentialEntryDependents.some((entryIdent) => ident.includes(entryIdent)) + ) { + const dependencies = modulesData.moduleDependencies(moduleIndex) + for (const dep of dependencies) { + const path = modulesData.module(dep)!.path + if (path.includes('next/dist/')) { + continue + } + const source = analyzeData.getSourceIndexFromPath(path) + if (source !== undefined) { + activeEntries.add(dep) + } + } + } + if (potentialEntries.some((entryIdent) => ident.includes(entryIdent))) { + activeEntries.add(moduleIndex) + } + } + + return Array.from(activeEntries) +} + +/** + * Compute module depth from active entries using BFS + * Returns a Map from ModuleIndex to depth + * Unreachable modules will not have an entry in the map + */ +export function computeModuleDepthMap( + modulesData: ModulesData, + activeEntries: ModuleIndex[] +): Map { + const depthMap = new Map() + const delayedModules = new Array<{ depth: number; queue: ModuleIndex[] }>() + + // Initialize queue with active entries + for (const moduleIndex of activeEntries) { + depthMap.set(moduleIndex, 0) + } + + // BFS to compute depth + // We need to insert new entries into the depth map in monotonic increasing order of depth + // so that we always process shallower modules before deeper ones + // This is important to avoid visiting modules multiple times and needing to decrease their depth + let i = 0 + for (const [moduleIndex, depth] of depthMap) { + const newDepth = depth + 1 + // Process regular dependencies + const dependencies = modulesData.moduleDependencies(moduleIndex) + for (const depIndex of dependencies) { + if (!depthMap.has(depIndex)) { + depthMap.set(depIndex, newDepth) + } + } + + // Process async dependencies with higher depth penalty + const asyncDependencies = modulesData.asyncModuleDependencies(moduleIndex) + for (const depIndex of asyncDependencies) { + if (!depthMap.has(depIndex)) { + const newDepth = depth + 1000 + // We can't directly insert async dependencies into the depth map + // because they might be processed before their parent module + // leading to incorrect depth assignment. + // Instead, we queue them to be processed later. + let delayedQueue = delayedModules.find((dq) => dq.depth === newDepth) + if (!delayedQueue) { + delayedQueue = { depth: newDepth, queue: [] } + delayedModules.push(delayedQueue) + // Keep delayed queues sorted by depth descending + delayedModules.sort((a, b) => b.depth - a.depth) + } + delayedQueue.queue.push(depIndex) + } + } + + i++ + + // Check if we need to process the next delayed queue to insert its items into the depth map + // This happens when we reach the end of the current queue + // or the next delayed queue has the same depth so its items need to be processed now + while ( + delayedModules.length > 0 && + (i === depthMap.size || + newDepth === delayedModules[delayedModules.length - 1].depth) + ) { + const { depth, queue } = delayedModules.pop()! + for (const depIndex of queue) { + if (!depthMap.has(depIndex)) { + depthMap.set(depIndex, depth) + } + } + } + } + + if (delayedModules.length > 0) { + throw new Error( + 'Internal error: delayed modules remain after BFS processing' + ) + } + + return depthMap +} diff --git a/apps/bundle-analyzer/lib/treemap-layout.ts b/apps/bundle-analyzer/lib/treemap-layout.ts index 67b023928a97af..48cd93f65ed90b 100644 --- a/apps/bundle-analyzer/lib/treemap-layout.ts +++ b/apps/bundle-analyzer/lib/treemap-layout.ts @@ -1,4 +1,4 @@ -import type { AnalyzeData } from './analyze-data' +import type { AnalyzeData, SourceIndex } from './analyze-data' import { layoutTreemap } from './layout-treemap' import { SpecialModule } from './types' import { getSpecialModuleType } from './utils' @@ -29,7 +29,7 @@ export interface LayoutNode extends LayoutNodeInfo { css?: boolean json?: boolean asset?: boolean - sourceIndex?: number // Track which source this node represents + sourceIndex?: SourceIndex // Track which source this node represents } interface SourceMetadata { @@ -39,7 +39,7 @@ interface SourceMetadata { function precomputeSourceMetadata( analyzeData: AnalyzeData, - filterSource?: (sourceIndex: number) => boolean + filterSource?: (sourceIndex: SourceIndex) => boolean ): SourceMetadata[] { const sourceCount = analyzeData.sourceCount() const metadata: SourceMetadata[] = new Array(sourceCount) @@ -65,7 +65,7 @@ function precomputeSourceMetadata( } // Top-down pass: aggregate child sizes and filtered status for directories - function processDirectory(idx: number) { + function processDirectory(idx: SourceIndex) { const children = analyzeData.sourceChildren(idx) if (children.length === 0) return // Already processed as leaf @@ -97,11 +97,11 @@ function precomputeSourceMetadata( // Internal function that uses precomputed metadata function computeTreemapLayoutFromAnalyzeInternal( analyzeData: AnalyzeData, - sourceIndex: number, + sourceIndex: SourceIndex, foldedPath: string, rect: LayoutRect, metadata: SourceMetadata[], - filterSource?: (sourceIndex: number) => boolean + filterSource?: (sourceIndex: SourceIndex) => boolean ): LayoutNode { const source = analyzeData.source(sourceIndex) if (!source) { @@ -164,7 +164,7 @@ function computeTreemapLayoutFromAnalyzeInternal( if (isCollapsed) { // Count all descendant files - function countDescendants(idx: number): number { + function countDescendants(idx: SourceIndex): number { const children = analyzeData.sourceChildren(idx) if (children.length === 0) return 1 return children.reduce( @@ -253,9 +253,9 @@ function computeTreemapLayoutFromAnalyzeInternal( // Public function that precomputes metadata and calls internal function export function computeTreemapLayoutFromAnalyze( analyzeData: AnalyzeData, - sourceIndex: number, + sourceIndex: SourceIndex, rect: LayoutRect, - filterSource?: (sourceIndex: number) => boolean + filterSource?: (sourceIndex: SourceIndex) => boolean ): LayoutNode { // Precompute metadata once for entire tree const metadata = precomputeSourceMetadata(analyzeData, filterSource) diff --git a/apps/bundle-analyzer/lib/utils.ts b/apps/bundle-analyzer/lib/utils.ts index 116b2c8cbe8758..13e04bd5b8fa69 100644 --- a/apps/bundle-analyzer/lib/utils.ts +++ b/apps/bundle-analyzer/lib/utils.ts @@ -2,7 +2,7 @@ import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' import { SpecialModule } from './types' import { NetworkError } from './errors' -import { AnalyzeData } from './analyze-data' +import { AnalyzeData, SourceIndex } from './analyze-data' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -29,7 +29,7 @@ export async function jsonFetcher(url: string): Promise { export function getSpecialModuleType( analyzeData: AnalyzeData | undefined, - sourceIndex: number | null + sourceIndex: SourceIndex | null ): SpecialModule | null { if (!analyzeData || sourceIndex == null) return null @@ -42,3 +42,19 @@ export function getSpecialModuleType( return null } + +let IDENT_ATTRIBUTES_REGEXP = + /^(.+?)(?: \{(.*)\})?(?: \[(.*)\])?(?: \((.*?)\))?(?: <(.*?)>)?$/ + +export function splitIdent(ident: string): { + fullPath: string + templateArgs: string + layer: string + moduleType: string + treeShaking: string +} { + let [match, fullPath, templateArgs, layer, moduleType, treeShaking] = + IDENT_ATTRIBUTES_REGEXP.exec(ident) || [''] + ident = ident.substring(0, ident.length - match.length) + return { fullPath, templateArgs, layer, moduleType, treeShaking } +} diff --git a/apps/bundle-analyzer/package.json b/apps/bundle-analyzer/package.json index e3fe2968aa5ba4..192de1686397a3 100644 --- a/apps/bundle-analyzer/package.json +++ b/apps/bundle-analyzer/package.json @@ -17,7 +17,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", - "lucide-react": "^0.454.0", + "lucide-react": "^0.554.0", "next": "16.0.1", "next-themes": "^0.4.6", "polished": "^4.3.1", diff --git a/crates/next-api/src/analyze.rs b/crates/next-api/src/analyze.rs index 788f2e7384a045..fffba1fd785d1f 100644 --- a/crates/next-api/src/analyze.rs +++ b/crates/next-api/src/analyze.rs @@ -6,7 +6,7 @@ use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use turbo_rcstr::RcStr; use turbo_tasks::{ - FxIndexSet, NonLocalValue, ResolvedVc, TryFlatJoinIterExt, ValueToString, Vc, + FxIndexSet, NonLocalValue, ResolvedVc, TryFlatJoinIterExt, TryJoinIterExt, ValueToString, Vc, trace::TraceRawVcs, }; use turbo_tasks_fs::{ @@ -72,8 +72,8 @@ pub struct AnalyzeSource { #[derive(Serialize)] pub struct AnalyzeModule { + pub ident: RcStr, pub path: RcStr, - pub depth: u32, } #[derive(Serialize)] @@ -283,18 +283,23 @@ impl ModulesDataBuilder { } } - fn ensure_module(&mut self, path: &str) -> (&mut AnalyzeModuleBuilder, u32) { - if let Some(&index) = self.module_index_map.get(path) { + fn get_module(&mut self, ident: &str) -> (&mut AnalyzeModuleBuilder, u32) { + if let Some(&index) = self.module_index_map.get(ident) { + return (&mut self.modules[index as usize], index); + } + panic!("Module with ident `{}` not found", ident); + } + + fn ensure_module(&mut self, ident: &str, path: &str) -> (&mut AnalyzeModuleBuilder, u32) { + if let Some(&index) = self.module_index_map.get(ident) { return (&mut self.modules[index as usize], index); } let index = self.modules.len() as u32; + let ident = RcStr::from(ident); let path = RcStr::from(path); - self.module_index_map.insert(path.clone(), index); + self.module_index_map.insert(ident.clone(), index); self.modules.push(AnalyzeModuleBuilder { - module: AnalyzeModule { - path, - depth: u32::MAX, - }, + module: AnalyzeModule { ident, path }, dependencies: FxIndexSet::default(), async_dependencies: FxIndexSet::default(), dependents: FxIndexSet::default(), @@ -420,11 +425,14 @@ pub async fn analyze_output_assets(output_assets: Vc) -> Result) -> Result> { let mut builder = ModulesDataBuilder::new(); + let mut all_modules = FxIndexSet::default(); let mut all_edges = FxIndexSet::default(); let mut all_async_edges = FxIndexSet::default(); for &module_graph in module_graphs.await? { let module_graph = module_graph.read_graphs().await?; module_graph.traverse_all_edges_unordered(|(parent_node, reference), node| { + all_modules.insert(parent_node); + all_modules.insert(node); match reference.chunking_type { ChunkingType::Async => { all_async_edges.insert((parent_node, node)); @@ -442,9 +450,24 @@ pub async fn analyze_module_graphs(module_graphs: Vc) -> Result) -> Result) -> Result) -> Result = builder.modules[current_index as usize] - .dependencies - .iter() - .copied() - .collect(); - - // Update dependencies - let new_depth = current_depth + 1; - for &dep_index in &dependencies { - let dep_module = &mut builder.modules[dep_index as usize]; - if new_depth < dep_module.module.depth { - dep_module.module.depth = new_depth; - queue.push_back(dep_index); - } - } - - // Collect async dependencies to avoid borrow conflicts - let async_dependencies: Vec = builder.modules[current_index as usize] - .async_dependencies - .iter() - .copied() - .collect(); - - // Update async dependencies - let new_depth = current_depth + 1000; - for &dep_index in &async_dependencies { - let dep_module = &mut builder.modules[dep_index as usize]; - if new_depth < dep_module.module.depth { - dep_module.module.depth = new_depth; - queue.push_back(dep_index); - } - } - } - let rope = builder.build(); Ok(FileContent::Content(File::from(rope)).cell()) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c775f7a03fe1ad..64455633d9a0d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -643,8 +643,8 @@ importers: specifier: 1.0.4 version: 1.0.4(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.3.0-canary-8ac5f4eb-20251119(react@19.3.0-canary-8ac5f4eb-20251119))(react@19.3.0-canary-8ac5f4eb-20251119) lucide-react: - specifier: ^0.454.0 - version: 0.454.0(react@19.3.0-canary-8ac5f4eb-20251119) + specifier: ^0.554.0 + version: 0.554.0(react@19.3.0-canary-8ac5f4eb-20251119) next: specifier: 16.0.1 version: 16.0.1(@babel/core@7.26.10)(@opentelemetry/api@1.6.0)(@playwright/test@1.51.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@0.0.0-experimental-3fde738-20250918)(react-dom@19.3.0-canary-8ac5f4eb-20251119(react@19.3.0-canary-8ac5f4eb-20251119))(react@19.3.0-canary-8ac5f4eb-20251119)(sass@1.77.8) @@ -12820,8 +12820,8 @@ packages: peerDependencies: react: 19.3.0-canary-8ac5f4eb-20251119 - lucide-react@0.454.0: - resolution: {integrity: sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==} + lucide-react@0.554.0: + resolution: {integrity: sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==} peerDependencies: react: 19.3.0-canary-8ac5f4eb-20251119 @@ -31817,7 +31817,7 @@ snapshots: dependencies: react: 19.3.0-canary-fb2177c1-20251114 - lucide-react@0.454.0(react@19.3.0-canary-8ac5f4eb-20251119): + lucide-react@0.554.0(react@19.3.0-canary-8ac5f4eb-20251119): dependencies: react: 19.3.0-canary-8ac5f4eb-20251119