Skip to main content
doc0blade47/next.jsdocumentation home
Sign in

Bundle Analyzer

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

The Bundle Analyzer provides an interactive web-based interface and data pipeline for visualizing, traversing, and diffing bundle sizes and module compositions across Next.js application builds. By parsing structured build artifacts and binary graph datasets emitted during production compilation, it empowers developers to inspect hierarchical directory structures, trace import dependencies, and pinpoint size regressions. Sources: apps/bundle-analyzer/app/layout.tsx:6-10, packages/next/src/build/analyze/graph-dump.ts:1-50, apps/bundle-analyzer/lib/analyze-data.ts:231-259

Build Graph Ingestion and Schema

CLI Export and Replay Pipeline

The analyzer pipeline initiates through the next analyze command or the export sub-pipeline managed by nextAnalyzeExport, which unpacks snapshot records without re-running compilation. The export flow reads history metadata from diagnostics/analyze/history/history.json and resolves target snapshots via snapshotNameSchema before invoking dumpAnalyzeGraph on the snapshot directory. Sources: packages/next/src/cli/next-analyze.ts:36-63

Option NameTypeDefaultDescription
experimentalAnalyzeboolean—Enables experimental build graph analysis workflows.
profilebooleanfalseActivates CPU profiling and React production profiling during analysis.
manglingbooleantrueControls variable mangling; disabling logs a debugging warning.
portnumber4000Port used by the interactive bundle analyzer web server.
outputbooleanfalseWrites results to disk instead of starting the interactive server.
experimentalAppOnlybooleanfalseRestricts analysis strictly to the App Router.
snapshotstring—Explicit identifier for capturing or loading a specific analysis snapshot.

Sources: packages/next/src/cli/next-analyze.ts:20-34

Note

Replaying snapshots via nextAnalyzeExport entirely bypasses next.config loading, lock acquisition, and fresh compilation artifacts, relying solely on previously serialized analysis records. Sources: packages/next/src/cli/next-analyze.ts:40-40

Binary Graph Data Structures and Schema Validation

Graph data is structured according to schema version 1 (urn:nextjs:analyze:analyzer-graph:v1), defining newline-delimited JSON record types: meta, module, route, output, part, and group. Binary files prepend a 4-byte big-endian JSON header length followed by the JSON payload and a packed binary edge section. Edges and offset indices are validated via validateEdges, ensuring record counts, offset monotonicity, and target index boundaries are strictly enforced. Sources: packages/next/src/build/analyze/graph-dump.ts:61-128, packages/next/analyze/graph-v1.schema.json:3-13

Sources: packages/next/src/build/analyze/graph-dump.ts:61-128

Warning

module_index_hash acts as an ordered, snapshot-scoped fingerprint of modules.data indices; it guarantees matching snapshot structures but does not serve as proof of identical graphs or build outputs. Sources: packages/next/analyze/graph-v1.schema.json:46-49

Caution

If header.schema_version differs from 1, readData throws an explicit validation error rather than attempting schema migration or fallback parsing. Sources: packages/next/src/build/analyze/graph-dump.ts:72-76

Client-Side Data Fetching and Models

SWR Data Hydration and Snapshot Resolution

The client bundle analyzer manages state via custom hooks built on top of useSWR. The useAnalyzerModel hook fetches history metadata, active route configurations, route summaries, and raw binary analyzer payloads. Static endpoints such as /data/metadata.json, ${comparisonBaseDir}/routes.json, and ${comparisonBaseDir}/route-summaries.json are retrieved with revalidateOnFocus and revalidateOnReconnect explicitly set to false. Dynamic data files including ${comparisonBaseDir}/modules.data and analyze.data are loaded with suspense: true enabled to coordinate asynchronous asset unpacking. Sources: apps/bundle-analyzer/components/analyzer.tsx:122-130, apps/bundle-analyzer/components/analyzer.tsx:165-198, apps/bundle-analyzer/components/analyzer.tsx:313-316, apps/bundle-analyzer/lib/analyzer-data.ts:6-14

Hook NameDefault SWR OptionsSuspensePurpose
useHistoryIndexrevalidateOnFocus: false, revalidateOnReconnect: false, shouldRetryOnError: falsefalseFetches history index data from /history/history.json.
useSuspenseJsonDatarevalidateOnFocus: false, revalidateOnReconnect: false (overridable)trueGeneric helper for fetching JSON assets with React Suspense enabled.
useSWR (modulesData)None specifiedtrueFetches binary module index data from ${comparisonBaseDir}/modules.data.
useSWR (analyzeData)revalidateOnFocus: false, revalidateOnReconnect: falsetrueFetches and parses binary route-specific data buffers into AnalyzeData instances.

Sources: apps/bundle-analyzer/components/analyzer.tsx:122-125, apps/bundle-analyzer/components/analyzer.tsx:165-191, apps/bundle-analyzer/components/analyzer.tsx:172-175, apps/bundle-analyzer/lib/analyzer-data.ts:6-33

Note

useAnalyzerModel invokes useSearchParams() before rendering suspense boundaries, allowing static prerendering bailouts to execute correctly prior to asynchronous data resolution. Sources: apps/bundle-analyzer/components/analyzer.tsx:106-108

Module Graph Model Generation and Scope Classification

Once module and analysis buffers are fetched, AnalyzeData parses a 4-byte big-endian JSON header length, decodes the UTF-8 JSON header metadata, and wraps the trailing binary buffer in a DataView. Module relationships are processed by computeActiveEntries, which scans modules matching known Next.js template identifiers such as next/dist/esm/build/templates/pages.js, next/dist/esm/build/templates/app-page.js, and [next]/entry/page-loader.ts. Sources: apps/bundle-analyzer/lib/analyze-data.ts:236-252, apps/bundle-analyzer/lib/module-graph.ts:25-79

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',
]

Sources: apps/bundle-analyzer/lib/module-graph.ts:29-40

Module depth maps are built via breadth-first search utilizing computeModuleDepthMap. Async dependencies impose a depth penalty of 1000 and are buffered into a delayed queue sorted in descending order of depth to guarantee correct hierarchical evaluation order. Finally, computeSourceLoadScopes classifies every source index into one of five load scopes: 'initial', 'async', 'mixed', 'traced', or 'unknown'. Sources: apps/bundle-analyzer/lib/module-graph.ts:86-167, apps/bundle-analyzer/lib/module-graph.ts:173-244

Scope NameBitmask ValueDescription
ModuleLoadScope.Initial1Modules loaded immediately during initial route entry.
ModuleLoadScope.Async2Modules loaded via asynchronous chunks or dynamic imports.
ModuleLoadScope.Traced4Modules discovered via dependency graph tracing heuristics.

Sources: apps/bundle-analyzer/lib/module-graph.ts:3-7

Caution

assertMatchingModuleIndex verifies that the module_index_hash string from ModulesData matches the fingerprint inside AnalyzeData, throwing an explicit error if a route record is paired with a mismatched module table. Sources: apps/bundle-analyzer/lib/analyze-data.ts:215-226

Treemap Layout Calculation

Hierarchical Directory Packing and Metadata Precomputation

The treemap layout engine processes hierarchical module data by first executing a bottom-up precomputation sweep across all sources via precomputeSourceMetadata. This pass calculates aggregate uncompressed and compressed sizes for every directory node and establishes whether a source is filtered out based on visibility rules. Sources: apps/bundle-analyzer/lib/treemap-layout.ts:83-142

export enum SizeMode {
  Compressed = 'compressed',
  Uncompressed = 'uncompressed',
}

Sources: apps/bundle-analyzer/lib/treemap-layout.ts:42-45

During recursive layout generation inside computeTreemapLayoutFromAnalyzeInternal, single-child directories are folded together when their combined path length does not exceed 40 characters, reducing visual clutter in deep folder structures. Sources: apps/bundle-analyzer/lib/treemap-layout.ts:162-180

Note

I> Directory nodes whose height drops below 30 pixels automatically switch to a collapsed state ('collapsed-directory'), bypassing child coordinate computation and rendering an aggregate item count instead. Sources: apps/bundle-analyzer/lib/treemap-layout.ts:206-237

Descendant Counting and Coordinate Computation

When a directory node is collapsed due to spatial constraints, it recursively counts all descendant files using a depth-first traversal of the child indices. Sources: apps/bundle-analyzer/lib/treemap-layout.ts:216-224

// Count all descendant files
function countDescendants(idx: SourceIndex): number {
  const children = analyzeData.sourceChildren(idx)
  if (children.length === 0) return 1
  return children.reduce(
    (sum, childIdx) => sum + countDescendants(childIdx),
    0
  )
}

Sources: apps/bundle-analyzer/lib/treemap-layout.ts:216-224

For uncollapsed directories with visible children, child nodes are filtered, mapped to their active size weights under either SizeMode.Compressed or SizeMode.Uncompressed, and sorted in descending order of size before invoking layoutTreemap to compute bounding rectangles within the content area. Sources: apps/bundle-analyzer/lib/treemap-layout.ts:240-282

SizeMode ConstantValue StringDescription
SizeMode.Compressed'compressed'Uses gzip/brotli compressed size for node sizing and weights.
SizeMode.Uncompressed'uncompressed'Uses raw uncompressed byte size for node sizing and weights.

Sources: apps/bundle-analyzer/lib/treemap-layout.ts:42-45

Interactive Treemap Canvas Rendering

Canvas Drawing Loop and Visual Hierarchy Styling

function getFileColor(
  node: {
    js?: boolean
    css?: boolean
    json?: boolean
    asset?: boolean
    server?: boolean
    client?: boolean
    traced?: boolean
    specialModuleType: SpecialModule | null
  },
  colors: CanvasColors
): string {
  const { js, css, json, asset, client, traced, specialModuleType } = node
 
  if (isPolyfill(specialModuleType)) {
    return colors.polyfill
  }
 
  let color = '#9ca3af' // gray-400 default
  if (js) color = '#4682b4'
  if (css) color = '#8b7d9e'
  if (json) color = '#297a3a'
  if (asset) color = '#da2f35'
 
  if (!client) {
    // Make it darker for server (30% darker)
    color = darken(0.3, color)
 
    if (traced) {
      // Make it slightly lighter (15% lighter than darkened)
      color = lighten(0.15, color)
    }
  }
  return color
}

Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:57-92

File tile coloring relies on file type flags and special module classifiers. Polyfill modules receive the dedicated polyfill color, while standard files are mapped using base hex values: #4682b4 for JavaScript (js), #8b7d9e for CSS (css), #297a3a for JSON (json), and #da2f35 for assets (asset), defaulting to #9ca3af. Non-client (server) modules apply a 0.3 darkening factor via darken(0.3, color), and if traced (traced), are lightened by 0.15 using lighten(0.15, color). Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:57-92

File Type / FlagBase Hex Color / TransformationDescription
Polyfill Modulecolors.polyfillApplied when isPolyfill() matches special module types.
JavaScript (js)#4682b4Default color for client/server JS files.
CSS (css)#8b7d9eStylesheet bundles.
JSON (json)#297a3aJSON data modules.
Asset (asset)#da2f35Static assets and binary files.
Server Non-Clientdarken(0.3, color)Darkens base module color by 30% for server environments.
Traced Serverlighten(0.15, darkenedColor)Lightens darkened server color by 15% when traced.

Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:57-92

Title and size font sizing inside rendered nodes is derived dynamically from the allocated title bar height using calculateTitleFontSizes. The title font size is restricted to a maximum of 10 pixels or half the title bar height (titleBarHeight * 0.5), while the size font takes the minimum of 9 pixels or titleFontSize - 2. Text measurement uses an LRU-style cache bounded at 30,000 entries (TEXT_WIDTH_CACHE_SIZE) combined with measureTextCached to prevent layout performance degradation. Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:101-138

Note

truncateTextWithEllipsisIfNeeded computes text fitting using binary search over the string length to insert ellipsis markers (...) without repeatedly polluting the text width cache with failed measurement attempts. Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:140-170

Hit Testing and Zooming Mechanics

function findNodeAtPosition(
  node: LayoutNode,
  x: number,
  y: number
): LayoutNode | null {
  const { rect } = node
 
  // Check if point is within this node's bounds
  if (
    x < rect.x ||
    x > rect.x + rect.width ||
    y < rect.y ||
    y > rect.y + rect.height
  ) {
    return null
  }
 
  if (node.type === 'collapsed-directory') {
    return node
  }
 
  // For regular directories, check if we're in the title bar first
  if (node.type === 'directory') {
    const titleBarHeight = node.titleBarHeight || 0
    if (y >= rect.y && y <= rect.y + titleBarHeight) {
      return node // Clicked on title bar
    }
  }
 
  // Check children (if any)
  if (node.children) {
    for (const child of node.children) {
      const found = findNodeAtPosition(child, x, y)
      if (found) return found
    }
  }
 
  return node
}

Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:172-210

Hit testing is executed by findNodeAtPosition, which recursively traverses layout nodes to locate the deepest matching rectangle enclosing coordinates (x, y). If a node is a collapsed directory ('collapsed-directory'), it returns immediately. For standard directory nodes, it checks whether the vertical coordinate falls within the title bar bounds (y >= rect.y && y <= rect.y + titleBarHeight), returning the directory node itself when matched; otherwise, it descends into child nodes. Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:172-210

Warning

Pressing the Escape key globally dispatches a window keydown event handler in AnalyzerModel that resets both the selected source index and focused source index back to the root source index of the active analysis data, provided an input or textarea element is not actively focused. Sources: apps/bundle-analyzer/components/analyzer.tsx:223-244

Import Chain Dependency Traversal

When a source file is selected in the bundle analyzer, the Sidebar component delegates rendering to SelectionDetails, which inspects whether the node contains child modules using analyzeData.sourceChildren(selectedSourceIndex).length > 0. If a node is a leaf module lacking child nodes, SelectionDetails renders the ImportChain component alongside output chunk identifiers extracted via analyzeData.sourceChunks(selectedSourceIndex).

{selectedSource && !hasChildModules && (
  <>
    <ImportChain
      startFileId={selectedSourceIndex}
      analyzeData={analyzeData}
      modulesData={modulesData}
      depthMap={moduleDepthMap}
      environmentFilter={environmentFilter}
    />
    {chunks.length > 0 ? (
      <div className="mt-2">
        <p className="text-xs font-semibold text-foreground">
          Output Chunks
        </p>
        <ul className="text-xs text-muted-foreground font-mono mt-1 space-y-1">
          {chunks.map((chunk) => (
            <li key={chunk} className="break-all">
              {chunk}
            </li>
          ))}
        </ul>
      </div>
    ) : null}
  </>
)}

Sources: apps/bundle-analyzer/components/sidebar.tsx:157-181

The ImportChain component manages route filtering through a checkbox bound to currentRouteOnly state, allowing users to toggle between including only dependent modules tied to the current route's bundle or inspecting all routes. State tracking for selected dependents across chain levels is maintained using selectedIndices. Handlers handlePrevious and handleNext update these indices by decrementing or incrementing with bounds-checking against level.totalCount - 1, slicing the index array back to levelIndex + 1.

export function ImportChain({
  startFileId,
  analyzeData,
  modulesData,
  depthMap,
  environmentFilter,
}: ImportChainProps) {
  // Filter to include only the current route
  const [currentRouteOnly, setCurrentRouteOnly] = useState(true)
 
  // Track which dependent is selected at each level
  const [selectedIndices, setSelectedIndices] = useState<number[]>([])
 
  const chain = buildImportChain(
    startFileId,
    analyzeData,
    modulesData,
    depthMap,
    environmentFilter,
    selectedIndices,
    currentRouteOnly
  )
 
  const handlePrevious = (levelIndex: number) => {
    setSelectedIndices((prev) => {
      const newIndices = [...prev]
      const currentIdx = newIndices[levelIndex] ?? 0
      const level = chain[levelIndex]
      newIndices[levelIndex] =
        currentIdx > 0 ? currentIdx - 1 : level.totalCount - 1
      return newIndices.slice(0, levelIndex + 1)
    })
  }
 
  const handleNext = (levelIndex: number) => {
    setSelectedIndices((prev) => {
      const newIndices = [...prev]
      const currentIdx = newIndices[levelIndex] ?? 0
      const level = chain[levelIndex]
      newIndices[levelIndex] =
        currentIdx < level.totalCount - 1 ? currentIdx + 1 : 0
      return newIndices.slice(0, levelIndex + 1)
    })
  }

Sources: apps/bundle-analyzer/components/import-chain.tsx:311-354

Layer Icons and Graph Traversal

Each level of the rendered import chain evaluates module layers against regular expressions to determine badge icon states. The layer types and corresponding icon mapping rules are outlined below:

Layer MatcherIcon ComponentIcon Color / StateDescription
/client/PanelTopGreen (text-green-500) if flags.client, else GrayIncluded on client-side of this route
/ssr/GlobeBlue (text-blue-500) if flags.server, else GrayIncluded on server-side rendering layer
/rsc/ServerOrange (text-orange-500) if flags.server, else GrayIncluded as Server Component
/route|api/SquareFunctionRed (text-red-500) if flags.server, else GrayIncluded for API routes
app / pages / externals-tracingBox / File / PackageLayer-specific colorsFallback handler via LayerIcon for App/Pages routers and external assets

Sources: apps/bundle-analyzer/components/import-chain.tsx:478-580

Warning

buildImportChain incorporates a hard safety limit that breaks out of dependent traversal loops if levelIndex > 100, preventing infinite recursion when circular dependencies exist in the module graph. Sources: apps/bundle-analyzer/components/import-chain.tsx:304-306

Bundle Comparison and Diffing

Synthetic Diff Layout Generation

The bundle analyzer computes build-to-build size differentials by joining leaf source entries across two AnalyzeData datasets using their stable full source paths. The diffSources function filters out directory aggregations and empty contributions where both size and compressed size evaluate to zero, collecting per-side leaf entries into maps keyed by file path. Each resulting diff row captures status flags, absolute sizes, compressed sizes, and OR-merged environment flags across both sides.

function collectLeaves(
  side: 'A' | 'B',
  data: AnalyzeData,
  options: SourceDiffOptions
): Map<string, LeafEntry> {
  const out = new Map<string, LeafEntry>()
  const count = data.sourceCount()
  for (let i = 0; i < count; i++) {
    // Only include leaf sources (real files, not directory aggregations).
    if (data.sourceChildren(i).length > 0) continue
    if (options.filterSource && !options.filterSource(side, i)) continue
    const fullPath = data.getFullSourcePath(i)
    if (!fullPath) continue
    const own = data.getOwnSizes(i)
    // Skip empty contributions — they aren't really "in" the bundle.
    if (own.size === 0 && own.compressedSize === 0) continue
    const flags = data.getSourceFlags(i)
    out.set(fullPath, {
      index: i,
      size: own.size,
      compressedSize: own.compressedSize,
      client: flags.client,
      server: flags.server,
    })
  }
  return out
}
 
/**
 * Diffs every source between two analyze datasets, joining by full source
 * path (stable across builds modulo file moves).
 *
 * Directories (sources with children) are excluded — only leaf "files" appear
 * in the resulting rows. Their own size is the sum of their {@link
 * AnalyzeData.sourceChunkParts} contributions.
 */
export function diffSources(
  analyzeA: AnalyzeData | null,
  analyzeB: AnalyzeData | null,
  options: SourceDiffOptions = {}
): DiffSummary<SourceDiffRow> {
  const summary = emptySummary<SourceDiffRow>()
 
  const leavesA = analyzeA
    ? collectLeaves('A', analyzeA, options)
    : new Map<string, LeafEntry>()
  const leavesB = analyzeB
    ? collectLeaves('B', analyzeB, options)
    : new Map<string, LeafEntry>()
 
  const allKeys = new Set<string>([...leavesA.keys(), ...leavesB.keys()])
  for (const key of allKeys) {
    const a = leavesA.get(key)
    const b = leavesB.get(key)
    const pretty = prettifySourcePath(key)
    pushRow(summary, {
      key,
      name: pretty.display,
      status: statusFor(
        a?.size ?? 0,
        b?.size ?? 0,
        a !== undefined,
        b !== undefined
      ),
      sizeA: a?.size ?? 0,
      sizeB: b?.size ?? 0,
      compressedA: a?.compressedSize ?? 0,
      compressedB: b?.compressedSize ?? 0,
      sourceIndexA: a?.index ?? null,
      sourceIndexB: b?.index ?? null,
      pathKind: pretty.kind,
      packageName: pretty.packageName,
      // OR-merge env flags across both sides so the badge reflects the
      // row's overall environment(s). A source that's client-only in A
      // and removed in B is still "client" for the purpose of the row.
      client: (a?.client ?? false) || (b?.client ?? false),
      server: (a?.server ?? false) || (b?.server ?? false),
    })
  }
 

Sources: apps/bundle-analyzer/lib/diff.ts:237-318

To visualize these differentials within the treemap interface, createDiffTreemapLayout constructs a synthetic hierarchical tree containing only changed modules. Path segments are parsed from row keys—treating Next.js project output paths starting with [project]/.next/ as unattributed output basenames—and nested directory nodes are instantiated dynamically.

export function createDiffTreemapLayout(
  rows: SourceDiffRow[],
  useCompressed: boolean
): DiffTreemapLayout {
  let nextIndex = 0
  const root: DiffTreeNode = {
    index: nextIndex++,
    name: 'All Changed Modules',
    parentIndex: null,
    size: 0,
    children: [],
  }
  const nodeByIndex = new Map<number, DiffTreeNode>([[root.index, root]])
  const directoryByPath = new Map<string, DiffTreeNode>([['', root]])
  const rowBySourceIndex = new Map<number, SourceDiffRow>()
  const sourceIndexByKey = new Map<string, number>()
 
  for (const row of rows) {
    const change = delta(row, useCompressed)
    if (change === 0) continue
 
    const parts = isNextBuildOutput(row.key)
      ? ['Unattributed output', getBasename(row.key)]
      : row.key.split('/').filter(Boolean)
    let parent = root
    let directoryPath = ''
 
    for (const part of parts.slice(0, -1)) {
      directoryPath += `/${part}`
      let directory = directoryByPath.get(directoryPath)
      if (!directory) {
        directory = {
          index: nextIndex++,
          name: `${part}/`,
          parentIndex: parent.index,
          size: 0,
          children: [],
        }
        parent.children.push(directory)
        directoryByPath.set(directoryPath, directory)
        nodeByIndex.set(directory.index, directory)
      }
      parent = directory
    }
 
    const leaf: DiffTreeNode = {
      index: nextIndex++,
      name: parts.at(-1) ?? row.name,
      parentIndex: parent.index,
      size: Math.abs(change),
      row,
      children: [],
    }
    parent.children.push(leaf)
    nodeByIndex.set(leaf.index, leaf)
    rowBySourceIndex.set(leaf.index, row)
    sourceIndexByKey.set(row.key, leaf.index)
  }

Sources: apps/bundle-analyzer/lib/diff-treemap-layout.ts:20-77

Note

The absolute magnitude of the change (Math.abs(change)) is assigned as the leaf node size, ensuring that tiles in the diff treemap directly represent size deltas rather than absolute file footprints. Sources: apps/bundle-analyzer/lib/diff-treemap-layout.ts:65-72

Layout Calculations and Color Mapping

Once leaf and directory node sizes are aggregated recursively via aggregateSize, computeLayout processes rectangular bounds by determining title bar heights and checking collapse states. If a directory rectangle's height drops below 30 pixels, it is rendered as a collapsed directory containing an item count; otherwise, child nodes are sorted descending by size and passed to the treemap layout engine.

function aggregateSize(node: DiffTreeNode): number {
  if (node.row) return node.size
  node.size = node.children.reduce(
    (total, child) => total + aggregateSize(child),
    0
  )
  return node.size
}
aggregateSize(root)
 
function computeLayout(node: DiffTreeNode, rect: LayoutRect): LayoutNode {
  if (node.row) {
    return {
      name: node.name,
      size: node.size,
      type: 'file',
      rect,
      sourceIndex: node.index,
      specialModuleType: null,
      client: node.row.client,
      server: node.row.server,
    }
  }
 
  const titleBarHeight = Math.round(
    Math.max(12, Math.min(24, rect.height * 0.1))
  )
  const isCollapsed = rect.height < 30
  if (isCollapsed) {
    return {
      name: node.name,
      size: node.size,
      type: 'collapsed-directory',
      rect,
      titleBarHeight,
      children: [],
      itemCount: countLeaves(node),
      sourceIndex: node.index,
      specialModuleType: null,
    }
  }
 
  const contentRect: LayoutRect = {
    x: Math.round(rect.x),
    y: Math.round(rect.y + titleBarHeight),
    width: Math.max(0, Math.round(rect.width - 2)),
    height: Math.max(0, Math.round(rect.height - titleBarHeight - 2)),
  }
  const children = [...node.children].sort((a, b) => b.size - a.size)
  const childRects = layoutTreemap(
    children.map((child) => child.size),
    contentRect
  )
 
  return {
    name: node.name,
    size: node.size,
    type: 'directory',
    rect,
    titleBarHeight,
    children: children.map((child, index) =>
      computeLayout(child, childRects[index])
    ),
    sourceIndex: node.index,
    specialModuleType: null,
  }
}

Sources: apps/bundle-analyzer/lib/diff-treemap-layout.ts:79-145

The DiffTreemap component overrides file colors and size labels using colorForRow, scaling fill opacities based on the relative magnitude of the change against baseline measurements.

  const diffLayout = useMemo(
    () => createDiffTreemapLayout(summary.rows, useCompressed),
    [summary.rows, useCompressed]
  )
  const { rowBySourceIndex, sourceIndexByKey } = diffLayout
 
  function getFileColorOverride(
    node: LayoutNode,
    colors: CanvasColors
  ): string | undefined {
    if (node.sourceIndex === undefined) return undefined
    const row = rowBySourceIndex.get(node.sourceIndex)
    return row ? colorForRow(row, useCompressed, colors) : undefined
  }
 
  // Show size deltas on changed tiles instead of absolute sizes.
  function getFileSizeLabel(node: LayoutNode): string | undefined {
    if (node.sourceIndex === undefined) return undefined
    const row = rowBySourceIndex.get(node.sourceIndex)
    if (!row || row.status === 'identical') return undefined
    return formatDelta(delta(row, useCompressed))
  }
 
  // Focus state stays local: it controls drill-in/zoom, which is purely a
  // visual concern of the treemap and shouldn't affect the sidebar.
  const initialRoot = diffLayout.rootIndex
  const [focusedSourceIndex, setFocusedSourceIndex] =
    useState<number>(initialRoot)
 
  // Translate selection into the synthetic tree's index. If the selected
  // path has no size delta, fall back to the root of changed files.
  const selectedSourceIndex =
    (selectedKey != null ? sourceIndexByKey.get(selectedKey) : undefined) ??
    initialRoot
 
  const handleSelectSourceIndex = (idx: number) => {
    if (!onSelectKey) return
    const row = rowBySourceIndex.get(idx)
    onSelectKey(row?.key ?? null)
  }
 
  if (!data) {
    return (
      <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
        No data for this route.
      </div>
    )
  }
 
  return (
    <TreemapVisualizer
      source={diffLayout}
      selectedSourceIndex={selectedSourceIndex}
      onSelectSourceIndex={handleSelectSourceIndex}
      focusedSourceIndex={focusedSourceIndex}
      onFocusSourceIndex={setFocusedSourceIndex}
      getFileSizeLabel={getFileSizeLabel}
      sizeMode={useCompressed ? SizeMode.Compressed : SizeMode.Uncompressed}
      getFileColorOverride={getFileColorOverride}
      searchQuery={searchQuery}
      overlay={<DiffLegend />}
    />
  )
}
 
/**
 * Picks a fill color for a diff row. Mirrors the previous DiffTreemap's
 * scheme: bright blue/amber for added/removed and lighter tints scaled by the
 * relative magnitude of the change for grew/shrank.
 */
function colorForRow(
  row: SourceDiffRow,
  useCompressed: boolean,
  colors: CanvasColors
): string {
  if (row.status === 'added') return colors.increase
  if (row.status === 'removed') return colors.decrease
  const deltaValue = useCompressed
    ? row.compressedB - row.compressedA
    : row.sizeB - row.sizeA

Sources: apps/bundle-analyzer/components/diff-treemap.tsx:63-150

Was this page helpful?