From 7f0568d5d1abbae22959dc74d326ca4c98098314 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:37:21 +0900 Subject: [PATCH] wip --- .../frontend-bundle-visualizer-report.mjs | 243 ++++++++++++------ .../frontend-bundle-visualizer-comment.yml | 8 +- .../workflows/frontend-bundle-visualizer.yml | 58 ++++- 3 files changed, 224 insertions(+), 85 deletions(-) diff --git a/.github/scripts/frontend-bundle-visualizer-report.mjs b/.github/scripts/frontend-bundle-visualizer-report.mjs index 240062e9c4..4423c6191f 100644 --- a/.github/scripts/frontend-bundle-visualizer-report.mjs +++ b/.github/scripts/frontend-bundle-visualizer-report.mjs @@ -1,14 +1,14 @@ import { readFile, writeFile } from 'node:fs/promises'; -const inputFile = process.argv[2]; -const outputFile = process.argv[3]; +const [beforeFile, afterFile, outputFile] = process.argv.slice(2); -if (inputFile == null || outputFile == null) { - console.error('Usage: node .github/scripts/frontend-bundle-visualizer-report.mjs '); +if (beforeFile == null || afterFile == null || outputFile == null) { + console.error('Usage: node .github/scripts/frontend-bundle-visualizer-report.mjs '); process.exit(1); } const byteFormatter = new Intl.NumberFormat('en-US'); +const numberFormatter = new Intl.NumberFormat('en-US'); function formatBytes(value) { if (!Number.isFinite(value) || value <= 0) return '0 B'; @@ -25,6 +25,10 @@ function formatBytes(value) { return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; } +function formatNumber(value) { + return numberFormatter.format(value); +} + function formatPercent(value) { return `${value.toFixed(1)}%`; } @@ -34,6 +38,38 @@ function sharePercent(value, total) { return formatPercent((value / total) * 100); } +function formatMathText(text) { + return text + .replaceAll('\\', '\\\\') + .replaceAll('{', '\\{') + .replaceAll('}', '\\}') + .replaceAll('%', '\\\\%'); +} + +function formatColoredDiff(text, diff) { + const color = diff > 0 ? 'orange' : 'green'; + return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`; +} + +function formatDiff(before, after, formatter) { + const diff = after - before; + if (diff === 0) return formatter(0); + + const sign = diff > 0 ? '+' : '-'; + return formatColoredDiff(`${sign}${formatter(Math.abs(diff))}`, diff); +} + +function formatDiffPercent(before, after) { + if (before === 0 && after === 0) return '0%'; + if (before === 0) return '-'; + + const diff = after - before; + if (diff === 0) return '0%'; + + const sign = diff > 0 ? '+' : '-'; + return formatColoredDiff(`${sign}${formatPercent(Math.abs(diff / before) * 100)}`, diff); +} + function tableCell(value) { return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' '); } @@ -52,96 +88,149 @@ function tableCode(value) { return tableCell(code(value)); } -const data = JSON.parse(await readFile(inputFile, 'utf8')); -const nodeParts = data.nodeParts ?? {}; -const nodeMetas = Object.values(data.nodeMetas ?? {}); -const moduleRows = []; -const bundleMap = new Map(); +function collectReport(data) { + const nodeParts = data.nodeParts ?? {}; + const nodeMetas = Object.values(data.nodeMetas ?? {}); + const moduleRows = []; + const bundleMap = new Map(); -for (const meta of nodeMetas) { - const row = { - id: meta.id, - bundles: 0, - renderedLength: 0, - gzipLength: 0, - brotliLength: 0, - importedByCount: meta.importedBy?.length ?? 0, - importedCount: meta.imported?.length ?? 0, - parts: [], - }; - - for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) { - const part = nodeParts[partUid]; - if (part == null) continue; - - row.bundles += 1; - row.renderedLength += part.renderedLength; - row.gzipLength += part.gzipLength; - row.brotliLength += part.brotliLength; - row.parts.push({ bundleId, ...part }); - - const bundle = bundleMap.get(bundleId) ?? { - id: bundleId, - modules: 0, + for (const meta of nodeMetas) { + const row = { + id: meta.id, + bundles: 0, renderedLength: 0, gzipLength: 0, brotliLength: 0, + importedByCount: meta.importedBy?.length ?? 0, + importedCount: meta.imported?.length ?? 0, }; - bundle.modules += 1; - bundle.renderedLength += part.renderedLength; - bundle.gzipLength += part.gzipLength; - bundle.brotliLength += part.brotliLength; - bundleMap.set(bundleId, bundle); - } - if (row.bundles > 0) { - moduleRows.push(row); - } -} + for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) { + const part = nodeParts[partUid]; + if (part == null) continue; -const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength); -const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength); + row.bundles += 1; + row.renderedLength += part.renderedLength; + row.gzipLength += part.gzipLength; + row.brotliLength += part.brotliLength; -let staticImports = 0; -let dynamicImports = 0; -for (const meta of nodeMetas) { - for (const imported of meta.imported ?? []) { - if (imported.dynamic) { - dynamicImports += 1; - } else { - staticImports += 1; + const bundle = bundleMap.get(bundleId) ?? { + id: bundleId, + modules: 0, + renderedLength: 0, + gzipLength: 0, + brotliLength: 0, + }; + bundle.modules += 1; + bundle.renderedLength += part.renderedLength; + bundle.gzipLength += part.gzipLength; + bundle.brotliLength += part.brotliLength; + bundleMap.set(bundleId, bundle); + } + + if (row.bundles > 0) { + moduleRows.push(row); } } + + let staticImports = 0; + let dynamicImports = 0; + for (const meta of nodeMetas) { + for (const imported of meta.imported ?? []) { + if (imported.dynamic) { + dynamicImports += 1; + } else { + staticImports += 1; + } + } + } + + const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength); + const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength); + const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0); + const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0); + const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0); + + return { + options: data.options ?? {}, + summary: { + bundles: bundleRows.length, + modules: moduleRows.length, + entries: nodeMetas.filter((meta) => meta.isEntry).length, + externals: nodeMetas.filter((meta) => meta.isExternal).length, + staticImports, + dynamicImports, + }, + metrics: { + renderedLength: totalRendered, + gzipLength: totalGzip, + brotliLength: totalBrotli, + }, + hotModules, + }; } -const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0); -const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0); -const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0); -const entries = nodeMetas.filter((meta) => meta.isEntry).length; -const externals = nodeMetas.filter((meta) => meta.isExternal).length; +function renderSummaryTable(before, after) { + const metrics = [ + { key: 'bundles', label: 'Bundles' }, + { key: 'modules', label: 'Modules' }, + { key: 'entries', label: 'Entries' }, + { key: 'externals', label: 'Externals' }, + { key: 'staticImports', label: 'Static Imports' }, + { key: 'dynamicImports', label: 'Dynamic Imports' }, + ]; + return [ + `| | ${metrics.map((metric) => metric.label).join(' | ')} |`, + `|---|${metrics.map(() => '---:').join('|')}|`, + `| Before | ${metrics.map((metric) => formatNumber(before.summary[metric.key])).join(' | ')} |`, + `| After | ${metrics.map((metric) => formatNumber(after.summary[metric.key])).join(' | ')} |`, + `| Diff | ${metrics.map((metric) => formatDiff(before.summary[metric.key], after.summary[metric.key], formatNumber)).join(' | ')} |`, + `| Diff (%) | ${metrics.map((metric) => formatDiffPercent(before.summary[metric.key], after.summary[metric.key])).join(' | ')} |`, + ]; +} + +function renderMetricTable(before, after) { + const metrics = [ + { key: 'renderedLength', label: 'Rendered size' }, + ]; + if (before.options.gzip || after.options.gzip) { + metrics.push({ key: 'gzipLength', label: 'Gzip size' }); + } + if (before.options.brotli || after.options.brotli) { + metrics.push({ key: 'brotliLength', label: 'Brotli size' }); + } + + const lines = [ + '| Metric | Before | After | Diff | Diff (%) |', + '|---|---:|---:|---:|---:|', + ]; + for (const metric of metrics) { + const beforeValue = before.metrics[metric.key]; + const afterValue = after.metrics[metric.key]; + lines.push(`| ${metric.label} | ${formatBytes(beforeValue)} | ${formatBytes(afterValue)} | ${formatDiff(beforeValue, afterValue, formatBytes)} | ${formatDiffPercent(beforeValue, afterValue)} |`); + } + + return lines; +} + +const beforeData = JSON.parse(await readFile(beforeFile, 'utf8')); +const afterData = JSON.parse(await readFile(afterFile, 'utf8')); +const before = collectReport(beforeData); +const after = collectReport(afterData); const lines = [ '# Bundle Report', '', - '| Bundles | Modules | Entries | Externals | Static Imports | Dynamic Imports |', - '|---:|---:|---:|---:|---:|---:|', - `| ${bundleRows.length} | ${moduleRows.length} | ${entries} | ${externals} | ${staticImports} | ${dynamicImports} |`, + ...renderSummaryTable(before, after), + '', + ...renderMetricTable(before, after), + '', + '## Top 10', '', - '| Metric | Total |', - '|---|---:|', - `| Rendered size | ${formatBytes(totalRendered)} |`, ]; -if (data.options?.gzip) { - lines.push(`| Gzip size | ${formatBytes(totalGzip)} |`); -} -if (data.options?.brotli) { - lines.push(`| Brotli size | ${formatBytes(totalBrotli)} |`); -} - -lines.push('', '## Top 10', ''); -for (const row of hotModules.slice(0, 10)) { - lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, totalRendered)} (${formatBytes(row.renderedLength)})`); +for (const row of after.hotModules.slice(0, 10)) { + lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`); } lines.push( @@ -152,8 +241,8 @@ lines.push( '|---|---:|---:|---:|---:|---:|---:|---:|', ); -for (const row of hotModules.slice(0, 15)) { - lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, totalRendered)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`); +for (const row of after.hotModules.slice(0, 15)) { + lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`); } await writeFile(outputFile, `${lines.join('\n')}\n`); diff --git a/.github/workflows/frontend-bundle-visualizer-comment.yml b/.github/workflows/frontend-bundle-visualizer-comment.yml index 44a8cef045..fcecc6515f 100644 --- a/.github/workflows/frontend-bundle-visualizer-comment.yml +++ b/.github/workflows/frontend-bundle-visualizer-comment.yml @@ -38,7 +38,7 @@ jobs: run-id: ${{ github.event.workflow_run.id }} - name: Generate visualizer report - run: node .github/scripts/frontend-bundle-visualizer-report.mjs "$RUNNER_TEMP/frontend-bundle-visualizer/stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" + run: node .github/scripts/frontend-bundle-visualizer-report.mjs "$RUNNER_TEMP/frontend-bundle-visualizer/before-stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/after-stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" - name: Comment on pull request uses: actions/github-script@v9 @@ -52,6 +52,7 @@ jobs: const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-visualizer'); const reportPath = path.join(reportDir, 'report.md'); const prNumberPath = path.join(reportDir, 'pr-number.txt'); + const baseShaPath = path.join(reportDir, 'base-sha.txt'); const headShaPath = path.join(reportDir, 'head-sha.txt'); const workflowRun = context.payload.workflow_run; const headSha = workflowRun.head_sha; @@ -104,11 +105,16 @@ jobs: } const report = fs.readFileSync(reportPath, 'utf8').trim(); + const baseSha = fs.existsSync(baseShaPath) + ? fs.readFileSync(baseShaPath, 'utf8').trim() + : null; const shortSha = headSha.slice(0, 7); + const shortBaseSha = baseSha != null ? baseSha.slice(0, 7) : null; let body = [ marker, '## Frontend bundle visualizer', '', + ...(shortBaseSha != null ? [`Base: \`${shortBaseSha}\``] : []), `Head: \`${shortSha}\``, '', report, diff --git a/.github/workflows/frontend-bundle-visualizer.yml b/.github/workflows/frontend-bundle-visualizer.yml index c8d8d64c2e..032a88409d 100644 --- a/.github/workflows/frontend-bundle-visualizer.yml +++ b/.github/workflows/frontend-bundle-visualizer.yml @@ -37,50 +37,94 @@ jobs: name: Build frontend bundle visualizer runs-on: ubuntu-latest steps: + - name: Checkout base + uses: actions/checkout@v6.0.2 + with: + repository: ${{ github.event.pull_request.base.repo.full_name }} + ref: ${{ github.event.pull_request.base.sha }} + path: before + submodules: true + - name: Checkout pull request uses: actions/checkout@v6.0.2 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} + path: after submodules: true + - name: Backport visualizer tooling to base if needed + shell: bash + run: | + if ! grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then + cp after/packages/frontend/package.json before/packages/frontend/package.json + cp after/packages/frontend/vite.config.ts before/packages/frontend/vite.config.ts + cp after/pnpm-lock.yaml before/pnpm-lock.yaml + fi + - name: Setup pnpm uses: pnpm/action-setup@v6.0.3 + with: + package_json_file: after/package.json - name: Setup Node.js uses: actions/setup-node@v6.4.0 with: - node-version-file: .node-version + node-version-file: after/.node-version cache: pnpm + cache-dependency-path: | + before/pnpm-lock.yaml + after/pnpm-lock.yaml - - name: Install dependencies + - name: Install dependencies for base + working-directory: before run: pnpm i --frozen-lockfile - - name: Build frontend dependencies + - name: Build frontend dependencies for base + working-directory: before run: pnpm --filter "frontend^..." run build - name: Prepare visualizer output run: mkdir -p "$RUNNER_TEMP/frontend-bundle-visualizer" - - name: Build frontend visualizer + - name: Build frontend visualizer for base + working-directory: before env: FRONTEND_BUNDLE_VISUALIZER: 'true' FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data - FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-visualizer/stats.json + FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-visualizer/before-stats.json + run: pnpm --filter frontend run build + + - name: Install dependencies for pull request + working-directory: after + run: pnpm i --frozen-lockfile + + - name: Build frontend dependencies for pull request + working-directory: after + run: pnpm --filter "frontend^..." run build + + - name: Build frontend visualizer for pull request + working-directory: after + env: + FRONTEND_BUNDLE_VISUALIZER: 'true' + FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data + FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-visualizer/after-stats.json run: pnpm --filter frontend run build - name: Generate visualizer report - run: node .github/scripts/frontend-bundle-visualizer-report.mjs "$RUNNER_TEMP/frontend-bundle-visualizer/stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" + run: node after/.github/scripts/frontend-bundle-visualizer-report.mjs "$RUNNER_TEMP/frontend-bundle-visualizer/before-stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/after-stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" - name: Check visualizer report run: | - test -s "$RUNNER_TEMP/frontend-bundle-visualizer/stats.json" + test -s "$RUNNER_TEMP/frontend-bundle-visualizer/before-stats.json" + test -s "$RUNNER_TEMP/frontend-bundle-visualizer/after-stats.json" test -s "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" cat "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" >> "$GITHUB_STEP_SUMMARY" - name: Write report metadata run: | printf '%s\n' "${{ github.event.pull_request.number }}" > "$RUNNER_TEMP/frontend-bundle-visualizer/pr-number.txt" + printf '%s\n' "${{ github.event.pull_request.base.sha }}" > "$RUNNER_TEMP/frontend-bundle-visualizer/base-sha.txt" printf '%s\n' "${{ github.event.pull_request.head.sha }}" > "$RUNNER_TEMP/frontend-bundle-visualizer/head-sha.txt" printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$RUNNER_TEMP/frontend-bundle-visualizer/pr-url.txt"