Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
c5ba17b
Scaffold tree-shake test app
piaccho Oct 1, 2025
e1a026f
Simplify app
piaccho Oct 2, 2025
b734f37
Improve report
piaccho Oct 2, 2025
9448c7a
Fix tsdown subpaths bundling
piaccho Oct 2, 2025
946ae2f
Add CI workflow
piaccho Oct 2, 2025
6a73e07
Update workflow trigger
piaccho Oct 2, 2025
72455e2
Fix CI
piaccho Oct 2, 2025
87660fb
Merge branch 'main' into chore/measure-tree-shakeability-in-ci
piaccho Oct 2, 2025
8e7327f
Fix lint
piaccho Oct 2, 2025
f178589
Refactor test and workflow
piaccho Oct 2, 2025
ee7d69e
modernize 🚀
iwoplaza Oct 30, 2025
c2831e3
Better
iwoplaza Oct 30, 2025
e1cf35a
Result comparison
iwoplaza Oct 30, 2025
1068ea3
Merge branch 'main' into chore/measure-tree-shakeability-in-ci
iwoplaza Jan 7, 2026
b7ee169
Include pnpm version
Jan 8, 2026
4932d8d
Remove type: module
Jan 8, 2026
8c14193
Update node version
Jan 8, 2026
371d229
Fix file extension
Jan 8, 2026
d03fb53
Add a token
Jan 8, 2026
921bca5
Remove token, add permissions
Jan 8, 2026
3ca9ac9
Fix tsdown, update .gitignore
Jan 8, 2026
378a4b3
Update script so it updates existing comments
Jan 8, 2026
85f6728
Remove pronly table and compare against itself instead
Jan 8, 2026
a583be3
Update table generation to handle missing tests
Jan 8, 2026
b0de4f9
Update to include sum instead of intersection of bundlers
Jan 8, 2026
cafbd21
Add `prettifySize`
Jan 9, 2026
899555f
Merge remote-tracking branch 'origin/main' into chore/measure-tree-sh…
Jan 9, 2026
4260438
Cleanup
Jan 9, 2026
0382215
Add deno.json
Jan 9, 2026
02ac2d9
Add better tests
Jan 9, 2026
69a0b8a
Fix assert
Jan 9, 2026
e320c67
Add more tests
Jan 9, 2026
82b4c1f
Update test names
Jan 9, 2026
7f5c13c
Rename examples to tests
Jan 9, 2026
ee0c6f8
Fix the refactor
Jan 9, 2026
6e0a9ef
Reorder jobs
Jan 9, 2026
1cc500d
Remove artifact uploading from the workflow
Jan 9, 2026
ac93e02
Revert job reorder
Jan 9, 2026
e4d6dcb
Display only new value
Jan 9, 2026
7c57041
Calculate against random value to check how it looks...
Jan 9, 2026
e8eac95
Bold increased sizes
Jan 9, 2026
e9cb51d
Replace bold with colored latex
Jan 9, 2026
a95cd8f
Add percent sign
Jan 9, 2026
1b14b00
Review fixes
Jan 9, 2026
0e7021c
Add unplugin
Jan 9, 2026
a0847bf
Remove `testUrl`
Jan 9, 2026
05d34f4
Smol
Jan 9, 2026
a68faf3
Merge remote-tracking branch 'origin/main' into chore/measure-tree-sh…
Jan 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions .github/workflows/treeshake-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
name: Tree-shake test

on:
pull_request:

jobs:
treeshake-test:
permissions:
contents: read
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.27.0
run_install: false

- name: Checkout PR branch
uses: actions/checkout@v4
with:
path: pr-branch

- name: Checkout target branch
uses: actions/checkout@v4
with:
path: target-branch
ref: ${{ github.base_ref }}

- name: Install dependencies (PR branch)
working-directory: pr-branch
run: pnpm install

- name: Install dependencies (target branch)
working-directory: target-branch
run: pnpm install

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'pnpm'
cache-dependency-path: |
pr-branch/pnpm-lock.yaml
target-branch/pnpm-lock.yaml

- name: Run tree-shake test on PR branch
working-directory: pr-branch
run: pnpm --filter treeshake-test test

- name: Run tree-shake test on target branch
working-directory: target-branch
run: pnpm --filter treeshake-test test

- name: Compare results
run: |
node pr-branch/apps/treeshake-test/compare-results.ts \
pr-branch/apps/treeshake-test/results.json \
target-branch/apps/treeshake-test/results.json \
> comparison.md

- name: Comment PR with results
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const comparison = fs.readFileSync('comparison.md', 'utf8');

const botCommentIdentifier = '## 📊 Bundle Size Comparison\n\n';

async function findBotComment(issueNumber) {
if (!issueNumber) return null;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
return comments.data.find((comment) =>
comment.body.includes(botCommentIdentifier)
);
}

async function createOrUpdateComment(issueNumber) {
if (!issueNumber) {
console.log('No issue number provided. Cannot post or update comment.');
return;
}

const existingComment = await findBotComment(issueNumber);
if (existingComment) {
await github.rest.issues.updateComment({
...context.repo,
comment_id: existingComment.id,
body: botCommentIdentifier + comparison,
});
} else {
await github.rest.issues.createComment({
...context.repo,
issue_number: issueNumber,
body: botCommentIdentifier + comparison,
});
}
}

const issueNumber = context.issue.number;
if (!issueNumber) {
console.log('No issue number found in context. Skipping comment.');
} else {
await createOrUpdateComment(issueNumber);
}
4 changes: 4 additions & 0 deletions apps/treeshake-test/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist/

results.md
results.json
180 changes: 180 additions & 0 deletions apps/treeshake-test/compare-results.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env node

import { arrayOf, type } from 'arktype';
import * as fs from 'node:fs/promises';

// Define schema for benchmark results
const ResultRecord = type({
testFilename: 'string',
bundler: 'string',
size: 'number',
});

const BenchmarkResults = arrayOf(ResultRecord);

function groupResultsByTest(results: typeof BenchmarkResults.infer) {
const grouped: Record<string, Record<string, number>> = {};
for (const result of results) {
if (!grouped[result.testFilename]) {
grouped[result.testFilename] = {};
}
// biome-ignore lint/style/noNonNullAssertion: it's there...
grouped[result.testFilename]![result.bundler] = result.size;
}
return grouped;
}

function calculateTrendMessage(
prSize: number | undefined,
targetSize: number | undefined,
): string {
if (prSize === undefined || targetSize === undefined) {
return '';
}
if (prSize === targetSize) {
return '(➖)';
}
const diff = prSize - targetSize;
const percent = ((diff / targetSize) * 100).toFixed(1);
if (diff > 0) {
return `($\${\\color{red}+${percent}\\\\%}$$)`;
}
return `($\${\\color{green}${percent}\\\\%}$$)`;
}

function prettifySize(size: number | undefined) {
if (size === undefined) {
return 'N/A';
}
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
let unitIndex = 0;
let sizeInUnits = size;
while (sizeInUnits > 1024 && unitIndex < units.length) {
sizeInUnits /= 1024;
unitIndex += 1;
}
return `${
Number.isInteger(sizeInUnits) ? sizeInUnits : sizeInUnits.toFixed(2)
} ${units[unitIndex]}`;
}

async function generateReport(
prResults: typeof BenchmarkResults.infer,
targetResults: typeof BenchmarkResults.infer,
) {
const prGrouped = groupResultsByTest(prResults);
const targetGrouped = groupResultsByTest(targetResults);

// Get all unique bundlers from both branches
const allBundlers = new Set([
...new Set(prResults.map((r) => r.bundler)),
...new Set(targetResults.map((r) => r.bundler)),
]);

// Get all unique tests from both branches
const allTests = new Set([
...Object.keys(prGrouped),
...Object.keys(targetGrouped),
]);

let output = '\n\n';

// Summary statistics
let totalIncrease = 0,
totalDecrease = 0,
totalUnchanged = 0,
totalUnknown = 0;

for (const test of allTests) {
for (const bundler of allBundlers) {
const prSize = prGrouped[test]?.[bundler];
const targetSize = targetGrouped[test]?.[bundler];

if (targetSize === undefined || prSize === undefined) totalUnknown++;
else if (prSize > targetSize) totalIncrease++;
else if (prSize < targetSize) totalDecrease++;
else totalUnchanged++;
}
}

output += '## 📈 Summary\n\n';
output += `- 📈 **Increased**: ${totalIncrease} bundles\n`;
output += `- 📉 **Decreased**: ${totalDecrease} bundles\n`;
output += `- ➖ **Unchanged**: ${totalUnchanged} bundles\n\n`;
output += `- ❔ **Unknown**: ${totalUnknown} bundles\n\n`;

// Main comparison table
output += '## 📋 Bundle Size Comparison\n\n';

// Table header
output += '| Test';
for (const bundler of allBundlers) {
output += ` | ${bundler}`;
}
output += ' |\n';

// Table separator
output += '|---------';
for (const _ of allBundlers) {
output += '|---------';
}
output += ' |\n';

// Table rows
for (const test of [...allTests].sort()) {
output += `| ${test}`;

for (const bundler of allBundlers) {
const prSize = prGrouped[test]?.[bundler];
const targetSize = targetGrouped[test]?.[bundler];

output += ` | ${prettifySize(prSize)} ${
calculateTrendMessage(prSize, Math.random() * 100000)
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coded Math.random() is used instead of targetSize for the comparison. This will produce incorrect trend messages showing random changes rather than actual differences between PR and target branches.

Suggested change
calculateTrendMessage(prSize, Math.random() * 100000)
calculateTrendMessage(prSize, targetSize)

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will change this before merging, but for now I'll keep it for illustration purposes

}`;
}
output += ' |\n';
}
output += '\n';

return output;
}

async function main() {
const [prFile, targetFile] = process.argv.slice(2);

if (!prFile || !targetFile) {
console.error(
'Usage: compare-results.js <pr-results.json> [target-results.json]',
);
process.exit(1);
}

// Read and validate PR results
let prResults: typeof BenchmarkResults.infer;
try {
const prContent = await fs.readFile(prFile, 'utf8');
prResults = BenchmarkResults.assert(JSON.parse(prContent));
} catch (error) {
throw new Error('PR results validation failed', { cause: error });
}

// Read and validate target results
let targetResults: typeof BenchmarkResults.infer = [];
if (targetFile) {
try {
const targetContent = await fs.readFile(targetFile, 'utf8');
targetResults = BenchmarkResults.assert(JSON.parse(targetContent));
} catch (error) {
console.warn('Could not read or validate target results:', error);
}
}

// Generate appropriate report
const markdownReport = await generateReport(
prResults,
targetResults,
);
console.log(markdownReport);
}

await main();
7 changes: 7 additions & 0 deletions apps/treeshake-test/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"exclude": ["."],
"fmt": {
"exclude": ["!."],
"singleQuote": true
}
}
66 changes: 66 additions & 0 deletions apps/treeshake-test/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as fs from 'node:fs/promises';
import {
bundleWithTsdown,
bundleWithWebpack,
getFileSize,
type ResultRecord,
} from './utils.ts';

const DIST_DIR = new URL('./dist/', import.meta.url);
const EXAMPLES_DIR = new URL('./tests/', import.meta.url);

/**
* A list of test filenames in the tests directory.
* E.g.: ['test1.ts', 'test2.ts', ...]
*/
const tests = await fs.readdir(EXAMPLES_DIR);

async function bundleTest(
testFilename: string,
bundler: string,
bundle: (testUrl: URL, outUrl: URL) => Promise<URL>,
): Promise<ResultRecord> {
const testUrl = new URL(testFilename, EXAMPLES_DIR);
const outUrl = await bundle(testUrl, DIST_DIR);
const size = await getFileSize(outUrl);

return { testFilename, bundler, size };
}

async function main() {
console.log('Starting bundler efficiency measurement...');
await fs.mkdir(DIST_DIR, { recursive: true });

const results = await Promise.allSettled(
tests.flatMap((test) => [
// https://github.com/software-mansion/TypeGPU/issues/2026
// bundleTest(test, 'esbuild', bundleWithEsbuild),
bundleTest(test, 'tsdown', bundleWithTsdown),
bundleTest(test, 'webpack', bundleWithWebpack),
]),
);

if (results.some((result) => result.status === 'rejected')) {
console.error('Some tests failed to bundle.');
for (const result of results) {
if (result.status === 'rejected') {
console.error(result.reason);
}
}
process.exit(1);
}

const successfulResults = (
results as PromiseFulfilledResult<ResultRecord>[]
).map((result) => result.value);

// Save results as JSON
await fs.writeFile(
'results.json',
JSON.stringify(successfulResults, null, 2),
);

console.log('\nMeasurement complete. Results saved to results.json');
}

await main();
24 changes: 24 additions & 0 deletions apps/treeshake-test/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "treeshake-test",
"private": true,
"version": "0.0.0",
"description": "Treeshake testing app for TypeGPU",
"type": "module",
"scripts": {
"test": "node index.ts"
},
"dependencies": {
"typegpu": "workspace:*",
"unplugin-typegpu": "^0.9.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"arktype": "1.0.29-alpha",
"esbuild": "^0.25.10",
"ts-loader": "^9.5.4",
"tsdown": "^0.15.6",
"typescript": "catalog:types",
"webpack": "^5.102.0",
"webpack-cli": "^6.0.1"
}
}
Loading