diff --git a/packages/rolldown/src/app/components/compare/ModulePanel.vue b/packages/rolldown/src/app/components/compare/ModulePanel.vue new file mode 100644 index 000000000..c981a12b9 --- /dev/null +++ b/packages/rolldown/src/app/components/compare/ModulePanel.vue @@ -0,0 +1,102 @@ + + + diff --git a/packages/rolldown/src/app/components/compare/SingleSideRow.vue b/packages/rolldown/src/app/components/compare/SingleSideRow.vue index ad018de7f..1d075457e 100644 --- a/packages/rolldown/src/app/components/compare/SingleSideRow.vue +++ b/packages/rolldown/src/app/components/compare/SingleSideRow.vue @@ -14,14 +14,21 @@ withDefaults(defineProps<{ delta: number format: 'bytes' | 'duration' | 'number' subtitle?: string + link?: string badges?: string[] stats?: SingleSideDiffStat[] + selectable?: boolean }>(), { subtitle: '', titleMeta: '', badges: () => [], stats: () => [], + selectable: false, }) + +const emit = defineEmits<{ + (e: 'select'): void +}>() @@ -656,20 +789,41 @@ function sumDelta(rows: SplitDiffRow[]) { :title="row.previousTitle || row.key" :title-meta="row.previousTitleMeta" :subtitle="row.previousSubtitle" + :link="row.previousLink" :badges="row.previousBadges" :stats="row.previousStats" :delta="row.delta" :format="row.format" + :selectable="props.tab === 'modules'" + @select="openModulePanel(row.key, 'previous')" /> +
+ Showing {{ TABLE_LIMIT }} of {{ activeDiffRows.length.toLocaleString() }} entries{{ props.tab === 'modules' ? ', filter to narrow down' : '' }} +
+
{{ activeDiffEmptyText }}
+ +
+ +
diff --git a/packages/rolldown/src/app/pages/compare/[sessions].vue b/packages/rolldown/src/app/pages/compare/[sessions].vue index 1d99f97c5..9f22963d2 100644 --- a/packages/rolldown/src/app/pages/compare/[sessions].vue +++ b/packages/rolldown/src/app/pages/compare/[sessions].vue @@ -35,6 +35,11 @@ const compareTabs = [ label: 'Chunks', icon: 'i-ph-shapes-duotone', }, + { + id: 'modules', + label: 'Modules', + icon: 'i-ph-graph-duotone', + }, { id: 'assets', label: 'Assets', diff --git a/packages/rolldown/src/app/pages/compare/[sessions]/modules.vue b/packages/rolldown/src/app/pages/compare/[sessions]/modules.vue new file mode 100644 index 000000000..bde2b945d --- /dev/null +++ b/packages/rolldown/src/app/pages/compare/[sessions]/modules.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/rolldown/src/node/rpc/functions/rolldown-get-session-compare-details.ts b/packages/rolldown/src/node/rpc/functions/rolldown-get-session-compare-details.ts index 3a8f4ec7f..9b8f28d47 100644 --- a/packages/rolldown/src/node/rpc/functions/rolldown-get-session-compare-details.ts +++ b/packages/rolldown/src/node/rpc/functions/rolldown-get-session-compare-details.ts @@ -8,12 +8,13 @@ import type { SessionCompareChunkDiff, SessionCompareDetails, SessionCompareMetricValue, + SessionCompareModuleDiff, SessionComparePackageDiff, SessionComparePluginDiff, } from '../../../shared/types' import type { RolldownEventsReader } from '../../rolldown/events-reader' import { defineRpcFunction } from '@vitejs/devtools-kit' -import { extname } from 'pathe' +import { extname, isAbsolute, relative } from 'pathe' import { getLogsManager } from '../utils' import { getPackageMeta } from './rolldown-get-packages' @@ -136,6 +137,44 @@ function getChunkSize(reader: RolldownEventsReader, chunk: RolldownChunkInfo | u }, 0) } +function getModuleSize(reader: RolldownEventsReader, id: string) { + return reader.manager.modules.get(id)?.build_metrics?.transforms?.at(-1)?.transformed_code_size ?? 0 +} + +function getModuleName(id: string, cwd: string) { + const [path = id, query] = id.split('?') + if (!isAbsolute(path)) + return id + // Show dependencies from their package root, instead of a long `../node_modules/.pnpm/...` path + const nodeModulesIndex = path.lastIndexOf('/node_modules/') + const name = nodeModulesIndex === -1 + ? relative(cwd, path) + : path.slice(nodeModulesIndex + '/node_modules/'.length) + return query == null ? name : `${name}?${query}` +} + +function getModulePackage(id: string) { + const match = id.match(/node_modules\/((?:@[^/]+\/)?[^/]+)\/(?!.*node_modules\/)/) + return match?.[1] +} + +function getChunkLabel(chunk: RolldownChunkInfo) { + return chunk.name || chunk.entry_module || `#${chunk.chunk_id}` +} + +function createModuleChunksMap(chunks: RolldownChunkInfo[]) { + const map = new Map() + for (const chunk of chunks) { + for (const id of chunk.modules) + map.set(id, [...(map.get(id) ?? []), chunk]) + } + return map +} + +function getSortedChunkValues(chunks: RolldownChunkInfo[], getValue: (chunk: RolldownChunkInfo) => string) { + return chunks.map(getValue).toSorted((a, b) => a.localeCompare(b)) +} + function getChunkKey(chunk: RolldownChunkInfo) { // Bracketed names are guessed (`[_]`) and change whenever // a module is added or removed, so match those chunks by entry or first module instead @@ -318,6 +357,47 @@ function compareChunks(previous: SessionCompareSource, current: SessionCompareSo })) } +// Same keys as `compareChunks`, including the `#2` suffix for duplicates, so the chunk filter lines up +function createChunkKeyMap(chunks: RolldownChunkInfo[]) { + return new Map(Array.from(createUniqueMap(chunks, getChunkKey), ([key, chunk]) => [chunk.chunk_id, key])) +} + +function compareModules(previous: SessionCompareSource, current: SessionCompareSource, cwd: string): SessionCompareModuleDiff[] { + const previousChunks = createModuleChunksMap(previous.chunks) + const currentChunks = createModuleChunksMap(current.chunks) + const previousChunkKeyMap = createChunkKeyMap(previous.chunks) + const currentChunkKeyMap = createChunkKeyMap(current.chunks) + // Only modules that end up in an output chunk contribute to the bundle + const previousModules = new Map(Array.from(previousChunks.keys(), id => [id, id])) + const currentModules = new Map(Array.from(currentChunks.keys(), id => [id, id])) + + return sortByDeltaImpact(compareMaps(previousModules, currentModules, (key, previousId, currentId) => { + const previousSize = previousId ? getModuleSize(previous.reader, previousId) : 0 + const currentSize = currentId ? getModuleSize(current.reader, currentId) : 0 + const previousModuleChunks = previousId ? previousChunks.get(previousId) ?? [] : [] + const currentModuleChunks = currentId ? currentChunks.get(currentId) ?? [] : [] + + const previousChunkKeys = getSortedChunkValues(previousModuleChunks, chunk => previousChunkKeyMap.get(chunk.chunk_id)!) + const currentChunkKeys = getSortedChunkValues(currentModuleChunks, chunk => currentChunkKeyMap.get(chunk.chunk_id)!) + const moved = !!previousId && !!currentId && previousChunkKeys.join('\0') !== currentChunkKeys.join('\0') + + return { + key, + status: getStatus(previousId, currentId, previousSize !== currentSize || moved), + name: getModuleName(key, cwd), + package: getModulePackage(key), + previousId, + currentId, + previousChunks: getSortedChunkValues(previousModuleChunks, getChunkLabel), + currentChunks: getSortedChunkValues(currentModuleChunks, getChunkLabel), + previousChunkKeys, + currentChunkKeys, + moved, + ...createMetric(previousSize, currentSize), + } + })) +} + function comparePackages(previous: SessionCompareSource, current: SessionCompareSource): SessionComparePackageDiff[] { const previousPackages = createPackageProfiles(previous.packages) const currentPackages = createPackageProfiles(current.packages) @@ -417,6 +497,7 @@ export const rolldownGetSessionCompareDetails = defineRpcFunction({ const assets = compareAssets(previous, current) const chunks = compareChunks(previous, current) + const modules = compareModules(previous, current, context.cwd) const packages = comparePackages(previous, current) const plugins = await comparePlugins(previous, current) @@ -433,6 +514,7 @@ export const rolldownGetSessionCompareDetails = defineRpcFunction({ }, assets, chunks, + modules, packages, plugins, } diff --git a/packages/rolldown/src/shared/types/data.ts b/packages/rolldown/src/shared/types/data.ts index 4a3907c85..08076f746 100644 --- a/packages/rolldown/src/shared/types/data.ts +++ b/packages/rolldown/src/shared/types/data.ts @@ -99,6 +99,20 @@ export interface SessionCompareChunkDiff extends SessionCompareMetricValue { currentInitial: boolean } +export interface SessionCompareModuleDiff extends SessionCompareMetricValue { + key: string + status: SessionCompareChangeStatus + name: string + package?: string + previousId?: string + currentId?: string + previousChunks: string[] + currentChunks: string[] + previousChunkKeys: string[] + currentChunkKeys: string[] + moved: boolean +} + export interface SessionComparePackageDiff extends SessionCompareMetricValue { key: string status: SessionCompareChangeStatus @@ -145,6 +159,7 @@ export interface SessionCompareDetails { } assets: SessionCompareAssetDiff[] chunks: SessionCompareChunkDiff[] + modules: SessionCompareModuleDiff[] packages: SessionComparePackageDiff[] plugins: SessionComparePluginDiff[] }