Bundle Size Treemap
This chart shows actual bundle size of tree-shaken Recharts app. Click to individual rectangles to see nested folders and files.
Measured entry
www/src/views/IndexView/IndexLineChart.tsxTree-shaken total9.50 KB
Minified + gzip3.00 KB
import { useEffect, useMemo, useState } from 'react';
import { Treemap, type TreemapNode, Tooltip } from 'recharts';
// #region Types, styles, and data helpers
type BundleVisualizationStage = {
readonly stage: 'es6-folder' | 'tree-shaken' | 'minified' | 'minified+gzip';
readonly bytes: number;
readonly humanReadableSize: string;
readonly reductionFromBaselinePercent: number;
};
type BundleVisualizationNode = {
readonly name: string;
readonly fullPath: string;
readonly value: number;
readonly children?: ReadonlyArray<BundleVisualizationNode>;
};
type BundleVisualizationEntry = {
readonly examplePath: string;
readonly totalSize: number;
readonly totalSizeLabel: string;
readonly moduleCount: number;
readonly generatedAt: string;
readonly stages: ReadonlyArray<BundleVisualizationStage>;
readonly tree: BundleVisualizationNode;
};
type BundleVisualizationData = {
readonly treemap: BundleVisualizationEntry;
};
type TreemapDisplayNode = {
readonly name: string;
readonly fullPath: string;
readonly value: number;
readonly fill: string;
readonly children?: ReadonlyArray<TreemapDisplayNode>;
};
type ColorSeed = {
readonly hue: number;
readonly saturation: number;
readonly lightness: number;
};
type BuildNode = {
name: string;
fullPath: string;
value: number;
children: Map<string, BuildNode>;
};
const generatedBundleVisualizationAssetPath = 'generated/bundleSizeData.generated.json';
const libraryColorSeeds: Record<string, ColorSeed> = {
recharts: { hue: 145, saturation: 46, lightness: 40 },
d3: { hue: 210, saturation: 66, lightness: 45 },
redux: { hue: 270, saturation: 46, lightness: 50 },
react: { hue: 194, saturation: 70, lightness: 47 },
example: { hue: 32, saturation: 78, lightness: 52 },
'es-toolkit': { hue: 18, saturation: 70, lightness: 49 },
};
const containerStyle = {
width: '100%',
maxWidth: '720px',
display: 'flex',
flexDirection: 'column' as const,
gap: '1rem',
};
const summaryGridStyle = {
display: 'grid',
gap: '0.75rem',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
};
const summaryCardStyle = {
border: '1px solid var(--color-border-1, #d0d7de)',
borderRadius: '0.75rem',
padding: '0.75rem 1rem',
backgroundColor: 'var(--color-surface-raised, #fff)',
};
const detailTextStyle = {
display: 'block',
opacity: 0.8,
wordBreak: 'break-word' as const,
};
const fallbackTree = {
name: 'bundle',
fullPath: 'bundle',
value: 9728,
children: [
{
name: 'node_modules',
fullPath: 'node_modules',
value: 4096,
children: [
{
name: 'react-redux',
fullPath: 'node_modules/react-redux',
value: 2304,
children: [{ name: 'es', fullPath: 'node_modules/react-redux/es', value: 2304 }],
},
{
name: 'victory-vendor',
fullPath: 'node_modules/victory-vendor',
value: 1792,
children: [{ name: 'es', fullPath: 'node_modules/victory-vendor/es', value: 1792 }],
},
],
},
{
name: 'src',
fullPath: 'src',
value: 4608,
children: [
{
name: 'chart',
fullPath: 'src/chart',
value: 2816,
children: [
{ name: 'Treemap.tsx', fullPath: 'src/chart/Treemap.tsx', value: 1536 },
{ name: 'SunburstChart.tsx', fullPath: 'src/chart/SunburstChart.tsx', value: 1280 },
],
},
{
name: 'component',
fullPath: 'src/component',
value: 1152,
children: [{ name: 'Tooltip.tsx', fullPath: 'src/component/Tooltip.tsx', value: 1152 }],
},
{
name: 'util',
fullPath: 'src/util',
value: 640,
children: [{ name: 'ChartUtils.ts', fullPath: 'src/util/ChartUtils.ts', value: 640 }],
},
],
},
{
name: 'entry',
fullPath: 'entry',
value: 1024,
children: [
{
name: 'www',
fullPath: 'entry/www',
value: 1024,
children: [
{
name: 'src',
fullPath: 'entry/www/src',
value: 1024,
children: [{ name: 'docs', fullPath: 'entry/www/src/docs', value: 1024 }],
},
],
},
],
},
],
} as const;
const fallbackStages = [
{
stage: 'es6-folder',
bytes: 65536,
humanReadableSize: '64.00 KB',
reductionFromBaselinePercent: 0,
},
{
stage: 'tree-shaken',
bytes: 9728,
humanReadableSize: '9.50 KB',
reductionFromBaselinePercent: 0,
},
{
stage: 'minified',
bytes: 6144,
humanReadableSize: '6.00 KB',
reductionFromBaselinePercent: 36.84,
},
{
stage: 'minified+gzip',
bytes: 3072,
humanReadableSize: '3.00 KB',
reductionFromBaselinePercent: 68.42,
},
] as const satisfies ReadonlyArray<BundleVisualizationStage>;
const fallbackEntry: BundleVisualizationEntry = {
examplePath: 'www/src/views/IndexView/IndexLineChart.tsx',
totalSize: fallbackTree.value,
totalSizeLabel: '9.50 KB',
moduleCount: 7,
generatedAt: 'placeholder',
stages: fallbackStages,
tree: fallbackTree,
};
type BundleVisualizationLoadState = {
readonly entry: BundleVisualizationEntry;
};
type BundleSizeTreemapProps = {
readonly forceFallbackData?: boolean;
};
type CustomizedTreemapNode = TreemapNode & {
readonly fill?: string;
};
let bundleVisualizationEntryPromise: Promise<BundleVisualizationEntry | null> | undefined;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}
function hashString(value: string): number {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash << 5) - hash + value.charCodeAt(index);
hash |= 0;
}
return Math.abs(hash);
}
function createBuildNode(name: string, fullPath: string): BuildNode {
return {
name,
fullPath,
value: 0,
children: new Map<string, BuildNode>(),
};
}
function getWebsiteBaseUrl(): string {
if (typeof document === 'undefined') {
return '/';
}
const moduleScript = document.querySelector<HTMLScriptElement>('script[type="module"][src]');
if (moduleScript?.src != null) {
const scriptUrl = new URL(moduleScript.src, window.location.href);
for (const marker of ['/src/', '/assets/']) {
const markerIndex = scriptUrl.pathname.indexOf(marker);
if (markerIndex >= 0) {
scriptUrl.pathname = scriptUrl.pathname.slice(0, markerIndex + 1);
scriptUrl.search = '';
scriptUrl.hash = '';
return scriptUrl.toString();
}
}
}
return new URL('/', window.location.href).toString();
}
function loadGeneratedBundleVisualizationEntry(): Promise<BundleVisualizationEntry | null> {
if (bundleVisualizationEntryPromise != null) {
return bundleVisualizationEntryPromise;
}
bundleVisualizationEntryPromise = fetch(new URL(generatedBundleVisualizationAssetPath, getWebsiteBaseUrl()))
.then(response => (response.ok ? response.json() : null))
.then((bundleVisualizationData: BundleVisualizationData | null) => bundleVisualizationData?.treemap ?? null)
.catch(() => null);
return bundleVisualizationEntryPromise;
}
function useBundleVisualizationEntry(forceFallbackData: boolean = false): BundleVisualizationLoadState {
const [loadState, setLoadState] = useState<BundleVisualizationLoadState>({
entry: fallbackEntry,
});
useEffect(() => {
if (forceFallbackData) {
setLoadState({
entry: fallbackEntry,
});
return undefined;
}
let isMounted = true;
loadGeneratedBundleVisualizationEntry().then(entry => {
if (entry == null || !isMounted) {
return;
}
setLoadState({
entry,
});
});
return () => {
isMounted = false;
};
}, [forceFallbackData]);
return loadState;
}
function collectLeafNodes(node: BundleVisualizationNode): Array<BundleVisualizationNode> {
if (node.children == null || node.children.length === 0) {
return [node];
}
return node.children.flatMap(collectLeafNodes);
}
function getPackageName(pathSegments: ReadonlyArray<string>): { packageName: string; packageSegmentCount: number } {
if (pathSegments[1]?.startsWith('@') && pathSegments[2] != null) {
return {
packageName: `${pathSegments[1]}/${pathSegments[2]}`,
packageSegmentCount: 2,
};
}
return {
packageName: pathSegments[1] ?? 'unknown-package',
packageSegmentCount: 1,
};
}
function inferLibraryFamily(packageName: string): string {
if (packageName.startsWith('d3-')) {
return 'd3';
}
if (packageName === '@reduxjs/toolkit' || packageName === 'react-redux' || packageName === 'redux') {
return 'redux';
}
if (packageName === 'react' || packageName === 'react-dom' || packageName === 'scheduler') {
return 'react';
}
if (packageName === 'use-sync-external-store') {
return 'react';
}
return packageName;
}
function getDisplayPathSegments(fullPath: string): Array<string> {
const pathSegments = fullPath.split('/');
if (pathSegments[0] === 'src') {
return ['recharts', ...pathSegments.slice(1)];
}
if (pathSegments[0] === 'entry') {
return ['example', ...pathSegments.slice(1)];
}
if (pathSegments[0] === 'node_modules') {
const { packageName, packageSegmentCount } = getPackageName(pathSegments);
const libraryFamily = inferLibraryFamily(packageName);
const pathInsidePackage = pathSegments.slice(1 + packageSegmentCount);
if (libraryFamily === packageName) {
return [libraryFamily, ...pathInsidePackage];
}
return [libraryFamily, packageName, ...pathInsidePackage];
}
return pathSegments;
}
function insertLeaf(root: BuildNode, pathSegments: ReadonlyArray<string>, value: number): void {
// eslint-disable-next-line no-param-reassign
root.value += value;
let currentNode = root;
const accumulatedPath: Array<string> = [];
pathSegments.forEach(segment => {
accumulatedPath.push(segment);
const existingChild = currentNode.children.get(segment);
const nextNode = existingChild ?? createBuildNode(segment, accumulatedPath.join('/'));
nextNode.value += value;
currentNode.children.set(segment, nextNode);
currentNode = nextNode;
});
}
// #endregion
function getLibraryColorSeed(libraryFamily: string): ColorSeed {
const predefinedColor = libraryColorSeeds[libraryFamily];
if (predefinedColor != null) {
return predefinedColor;
}
return {
hue: hashString(libraryFamily) % 360,
saturation: 44 + (hashString(`${libraryFamily}-s`) % 18),
lightness: 42 + (hashString(`${libraryFamily}-l`) % 8),
};
}
function getNodeFill(
libraryFamily: string,
fullPath: string,
depth: number,
siblingIndex: number,
siblingCount: number,
): string {
const baseColor = getLibraryColorSeed(libraryFamily);
const siblingPosition = siblingCount > 1 ? siblingIndex / (siblingCount - 1) - 0.5 : 0;
const lightnessOffset = siblingPosition * 16 + depth * 6 + ((hashString(fullPath) % 5) - 2);
const saturationOffset = -depth * 4 + ((hashString(`${fullPath}-sat`) % 5) - 2);
return `hsl(${baseColor.hue} ${clamp(baseColor.saturation + saturationOffset, 28, 82)}% ${clamp(baseColor.lightness + lightnessOffset, 24, 78)}%)`;
}
function finalizeDisplayNode(
node: BuildNode,
libraryFamily: string,
depth: number,
siblingIndex: number,
siblingCount: number,
): TreemapDisplayNode {
const sortedChildren = Array.from(node.children.values()).sort((left, right) => right.value - left.value);
return {
name: node.name,
fullPath: node.fullPath,
value: node.value,
fill: getNodeFill(libraryFamily, node.fullPath, depth, siblingIndex, siblingCount),
children:
sortedChildren.length > 0
? sortedChildren.map((child, index) =>
finalizeDisplayNode(child, libraryFamily, depth + 1, index, sortedChildren.length),
)
: undefined,
};
}
function buildTreemapDisplayData(tree: BundleVisualizationNode): ReadonlyArray<TreemapDisplayNode> {
const displayRoot = createBuildNode('bundle', 'bundle');
collectLeafNodes(tree).forEach(leafNode => {
insertLeaf(displayRoot, getDisplayPathSegments(leafNode.fullPath), leafNode.value);
});
const topLevelLibraries = Array.from(displayRoot.children.values()).sort((left, right) => right.value - left.value);
return topLevelLibraries.map((libraryNode, index) =>
finalizeDisplayNode(libraryNode, libraryNode.name, 0, index, topLevelLibraries.length),
);
}
function getHumanReadableSize(sizeInBytes: number): string {
if (sizeInBytes < 1024) {
return `${sizeInBytes} B`;
}
const units = ['KB', 'MB', 'GB', 'TB'];
let value = sizeInBytes;
let unitIndex = -1;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(2)} ${units[unitIndex]}`;
}
function renderCustomizedTreemapNode(props: CustomizedTreemapNode) {
const { x = 0, y = 0, width = 0, height = 0, depth = 0, name, fill = '#8884d8' } = props;
if (width <= 1 || height <= 1) {
return <g />;
}
const canRenderLabel = width > 72 && height > 26;
const canRenderValue = width > 112 && height > 42;
const fontSize = depth === 0 ? 15 : 12;
const strokeWidth = depth === 0 ? 2 : 1;
return (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
fill={fill}
stroke="var(--color-surface-base)"
strokeWidth={strokeWidth}
rx={4}
/>
{canRenderLabel ? (
<text x={x + 8} y={y + 18} fill="#fff" fontSize={fontSize} fontWeight={depth === 0 ? 700 : 500}>
{name}
</text>
) : null}
{canRenderValue ? (
<text x={x + 8} y={y + 34} fill="rgba(255, 255, 255, 0.85)" fontSize={11}>
{getHumanReadableSize(props.value)}
</text>
) : null}
</g>
);
}
function BundleSizeTreemap({ forceFallbackData = false }: BundleSizeTreemapProps) {
const { entry: bundleVisualizationEntry } = useBundleVisualizationEntry(forceFallbackData);
const minifiedGzipStage = useMemo(
() => bundleVisualizationEntry.stages.find(stage => stage.stage === 'minified+gzip'),
[bundleVisualizationEntry.stages],
);
const treemapData = useMemo(
() => buildTreemapDisplayData(bundleVisualizationEntry.tree),
[bundleVisualizationEntry.tree],
);
return (
<div style={containerStyle}>
<div style={summaryGridStyle}>
<div style={summaryCardStyle}>
<strong>Measured entry</strong>
<span style={detailTextStyle}>
<code>{bundleVisualizationEntry.examplePath}</code>
</span>
</div>
<div style={summaryCardStyle}>
<strong>Tree-shaken total</strong>
<span style={detailTextStyle}>{bundleVisualizationEntry.totalSizeLabel}</span>
</div>
<div style={summaryCardStyle}>
<strong>Minified + gzip</strong>
<span style={detailTextStyle}>{minifiedGzipStage?.humanReadableSize ?? 'n/a'}</span>
</div>
</div>
<Treemap
style={{ width: '100%', maxWidth: '720px', maxHeight: '80vh', aspectRatio: 4 / 3 }}
data={treemapData}
dataKey="value"
nameKey="name"
aspectRatio={4 / 3}
stroke="#fff"
fill="#2f855a"
type="nest"
content={renderCustomizedTreemapNode}
>
<Tooltip />
</Treemap>
</div>
);
}
export default BundleSizeTreemap;