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
+}>()
@@ -32,7 +39,13 @@ withDefaults(defineProps<{
{{ sessionLabel }}
- {{ title }}
+
+
+ {{ title }}
+
+ {{ title }}
({{ titleMeta }})
diff --git a/packages/rolldown/src/app/components/compare/SplitRow.vue b/packages/rolldown/src/app/components/compare/SplitRow.vue
index b22b61825..291568643 100644
--- a/packages/rolldown/src/app/components/compare/SplitRow.vue
+++ b/packages/rolldown/src/app/components/compare/SplitRow.vue
@@ -16,6 +16,8 @@ const props = withDefaults(defineProps<{
currentTitleMeta?: string
previousSubtitle?: string
currentSubtitle?: string
+ previousLink?: string
+ currentLink?: string
previousStats?: SplitDiffStat[]
currentStats?: SplitDiffStat[]
previousBadges?: string[]
@@ -25,14 +27,20 @@ const props = withDefaults(defineProps<{
delta: number
format: 'bytes' | 'duration' | 'number'
ratioText?: string
+ selectable?: boolean
}>(), {
previousStats: () => [],
currentStats: () => [],
previousBadges: () => [],
currentBadges: () => [],
ratioText: '',
+ selectable: false,
})
+const emit = defineEmits<{
+ (e: 'select', side: 'previous' | 'current'): void
+}>()
+
const ratioClass = computed(() => {
if (props.delta > 0)
return 'text-red-500'
@@ -62,7 +70,13 @@ const ratioClass = computed(() => {
Session A
- {{ previousTitle }}
+
+
+ {{ previousTitle }}
+
+ {{ previousTitle }}
({{ previousTitleMeta }})
@@ -92,7 +106,13 @@ const ratioClass = computed(() => {
Session B
- {{ currentTitle }}
+
+
+ {{ currentTitle }}
+
+ {{ currentTitle }}
({{ currentTitleMeta }})
diff --git a/packages/rolldown/src/app/components/compare/TabView.vue b/packages/rolldown/src/app/components/compare/TabView.vue
index d45ce450f..241d2e29b 100644
--- a/packages/rolldown/src/app/components/compare/TabView.vue
+++ b/packages/rolldown/src/app/components/compare/TabView.vue
@@ -7,8 +7,9 @@ import type {
} from '~~/shared/types'
import { bytesToHumanSize } from '@vitejs/devtools-ui/utils/format'
import { computed, ref, watch } from 'vue'
+import { useRoute, useRouter } from '#app/composables/router'
-type CompareTab = 'overview' | 'plugins' | 'chunks' | 'assets' | 'packages'
+type CompareTab = 'overview' | 'plugins' | 'chunks' | 'modules' | 'assets' | 'packages'
type DiffMode = 'changed' | 'added' | 'removed'
type DiffFormat = 'bytes' | 'duration' | 'number'
type SummaryTone = 'increase' | 'decrease'
@@ -37,6 +38,8 @@ interface SplitDiffRow extends SessionCompareMetricValue {
currentTitleMeta?: string
previousSubtitle?: string
currentSubtitle?: string
+ previousLink?: string
+ currentLink?: string
previousStats?: SplitDiffStat[]
currentStats?: SplitDiffStat[]
previousBadges: string[]
@@ -54,7 +57,31 @@ const props = defineProps<{
const TABLE_LIMIT = 300
+const route = useRoute()
+const router = useRouter()
+
const activeDiffMode = ref
('changed')
+const moduleSearch = ref('')
+
+const selectedModule = ref<{
+ previous?: { session: string, module: string }
+ current?: { session: string, module: string }
+ side: 'previous' | 'current'
+} | null>(null)
+
+function openModulePanel(key: string, side: 'previous' | 'current') {
+ const [sessionA, sessionB] = props.sessions
+ const diff = props.details?.modules.find(item => item.key === key)
+ if (!diff)
+ return
+ selectedModule.value = {
+ previous: diff.previousId && sessionA ? { session: sessionA.id, module: diff.previousId } : undefined,
+ current: diff.currentId && sessionB ? { session: sessionB.id, module: diff.currentId } : undefined,
+ side,
+ }
+}
+const moduleChunkFilter = computed(() => typeof route.query.chunk === 'string' ? route.query.chunk : undefined)
+const moduleChunkFilterName = computed(() => props.details?.chunks.find(chunk => chunk.key === moduleChunkFilter.value)?.name ?? moduleChunkFilter.value)
const activeTabMeta = computed(() => props.tabs.find(tab => tab.id === props.tab))
const comparisonMetrics = computed(() => {
@@ -167,6 +194,26 @@ const sessionSummaries = computed(() => {
tone: getSummaryTone(index, session.initial_js, sessionA?.initial_js),
},
])
+ case 'modules': {
+ return props.sessions.map((session, index) => {
+ const codeSize = sumCompareSide(props.details?.modules, index, item => item.previous, item => item.current)
+ const codeSizeA = sumCompareSide(props.details?.modules, 0, item => item.previous, item => item.current)
+ return [
+ {
+ label: 'Modules',
+ value: session.modules.toLocaleString(),
+ icon: 'i-ph-graph-duotone',
+ tone: getSummaryTone(index, session.modules, sessionA?.modules),
+ },
+ {
+ label: 'Code Size',
+ value: formatBytesSummary(codeSize),
+ icon: 'i-ph-file-code-duotone',
+ tone: getSummaryTone(index, codeSize, codeSizeA),
+ },
+ ]
+ })
+ }
case 'packages': {
const packageStatsA = getPackageSummaryStats(0)
return props.sessions.map((_session, index) => {
@@ -226,6 +273,17 @@ const sessionSummaries = computed(() => {
const assetDiffs = computed(() => changedEntries(props.details?.assets))
const chunkDiffs = computed(() => changedEntries(props.details?.chunks))
+const moduleDiffs = computed(() => {
+ const chunk = moduleChunkFilter.value
+ const search = moduleSearch.value.trim().toLowerCase()
+ return changedEntries(props.details?.modules).filter((item) => {
+ if (chunk && !item.previousChunkKeys.includes(chunk) && !item.currentChunkKeys.includes(chunk))
+ return false
+ if (search && !item.name.toLowerCase().includes(search) && !item.package?.toLowerCase().includes(search))
+ return false
+ return true
+ })
+})
const packageDiffs = computed(() => changedEntries(props.details?.packages))
const pluginDiffs = computed(() => changedEntries(props.details?.plugins))
@@ -250,6 +308,8 @@ const chunkRows = computed(() => chunkDiffs.value.map(item => cr
status: item.status,
previousTitle: item.previousChunkId == null ? undefined : item.name,
currentTitle: item.currentChunkId == null ? undefined : item.name,
+ previousLink: item.previousChunkId == null ? undefined : getChunkModulesLink(item.key),
+ currentLink: item.currentChunkId == null ? undefined : getChunkModulesLink(item.key),
previousStats: createChunkStats(item.previousModules, item.previousImports),
currentStats: createChunkStats(item.currentModules, item.currentImports, {
modules: item.previousModules,
@@ -270,6 +330,29 @@ const chunkRows = computed(() => chunkDiffs.value.map(item => cr
].filter(Boolean) as string[],
})))
+const moduleRows = computed(() => {
+ const [sessionA, sessionB] = props.sessions
+ return moduleDiffs.value.map(item => createSplitRow({
+ key: item.key,
+ status: item.status,
+ previousTitle: item.previousId == null ? undefined : item.name,
+ currentTitle: item.currentId == null ? undefined : item.name,
+ previousSubtitle: formatModuleChunks(item.previousChunks),
+ currentSubtitle: formatModuleChunks(item.currentChunks),
+ previousLink: item.previousId == null || !sessionA ? undefined : getModuleLink(sessionA.id, item.previousId),
+ currentLink: item.currentId == null || !sessionB ? undefined : getModuleLink(sessionB.id, item.currentId),
+ previous: item.previous,
+ current: item.current,
+ delta: item.delta,
+ deltaRatio: item.deltaRatio,
+ format: 'bytes',
+ previousBadges: item.package ? [item.package] : [],
+ currentBadges: item.moved
+ ? [item.package, 'moved'].filter(Boolean) as string[]
+ : item.package ? [item.package] : [],
+ }))
+})
+
const packageRows = computed(() => packageDiffs.value.map(item => createSplitRow({
key: item.key,
status: item.status,
@@ -322,6 +405,8 @@ const activeRows = computed(() => {
return assetRows.value
case 'chunks':
return chunkRows.value
+ case 'modules':
+ return moduleRows.value
case 'packages':
return packageRows.value
case 'plugins':
@@ -393,6 +478,7 @@ watch(
[() => props.tab, () => props.details],
() => {
activeDiffMode.value = diffModeOptions.value.find(item => item.count > 0)?.id ?? 'changed'
+ selectedModule.value = null
},
{ immediate: true },
)
@@ -420,6 +506,24 @@ function formatAssetTitleMeta(chunkName: string | undefined) {
return chunkName?.replace(/[[\]]/g, '')
}
+function formatModuleChunks(chunks: string[]) {
+ if (!chunks.length)
+ return undefined
+ return `in ${chunks.map(chunk => chunk.replace(/[[\]]/g, '')).join(', ')}`
+}
+
+function getChunkModulesLink(chunkKey: string) {
+ return `/compare/${props.sessions.map(session => session.id).join(',')}/modules?chunk=${encodeURIComponent(chunkKey)}`
+}
+
+function clearModuleChunkFilter() {
+ router.replace({ query: { ...route.query, chunk: undefined } })
+}
+
+function getModuleLink(session: string, module: string) {
+ return `/session/${session}/graph?module=${encodeURIComponent(module)}`
+}
+
function formatPackageTitle(name: string, version: string | undefined) {
if (!version || version === '(unknown)')
return name
@@ -586,6 +690,28 @@ function sumDelta(rows: SplitDiffRow[]) {
+
+
+
+
+
@@ -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 }}
+
+
+ selectedModule && (selectedModule.side = side)"
+ />
+
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[]
}