From 653e7189890d10d1a25b0586237881a762119b89 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 1 Jul 2026 18:11:36 +0200 Subject: [PATCH 1/7] ci: track package bundle size with build report and PR comparison Add tools/bundle-size.ts to measure appkit and appkit-ui bundle size: tarball packed/unpacked, dist raw/gzip totals, and per-entry minified+gzip import cost (esbuild, deps external). Appended to `pnpm build` for an end-of-build report, and exposed as `pnpm size` / `size:baseline` / `size:compare`. A new bundle-size workflow diffs each PR against a committed baseline (bundle-size-baseline.json), posts a sticky comment, and fails only when a package's packed tarball grows past the budget (>5% and >10 KB). A push-to-main job regenerates and commits the baseline. Signed-off-by: MarioCadenas --- .../scripts/upsert-bundle-size-comment.cjs | 42 ++ .github/workflows/bundle-size.yml | 111 +++++ bundle-size-baseline.json | 62 +++ package.json | 5 +- tools/bundle-size.ts | 392 ++++++++++++++++++ 5 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/upsert-bundle-size-comment.cjs create mode 100644 .github/workflows/bundle-size.yml create mode 100644 bundle-size-baseline.json create mode 100644 tools/bundle-size.ts diff --git a/.github/scripts/upsert-bundle-size-comment.cjs b/.github/scripts/upsert-bundle-size-comment.cjs new file mode 100644 index 000000000..7374fd5b3 --- /dev/null +++ b/.github/scripts/upsert-bundle-size-comment.cjs @@ -0,0 +1,42 @@ +/** + * Upserts a sticky PR comment with the bundle-size report. + * + * Invoked via `actions/github-script`. The comment body is generated by + * `tools/bundle-size.ts --compare --markdown ` and its path is passed in + * via the COMMENT_PATH environment variable. The body already carries the + * marker below as its first line, which we reuse to find the existing comment. + */ + +const fs = require("node:fs"); + +const MARKER = ""; + +module.exports = async ({ github, context }) => { + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const body = fs.readFileSync(process.env.COMMENT_PATH, "utf-8"); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find((c) => c.body?.includes(MARKER)); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } +}; diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 000000000..c069dabdc --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,111 @@ +name: Bundle Size + +# On PRs: build the packages, diff their sizes against the committed baseline +# (bundle-size-baseline.json), post a sticky comment, and fail the check only +# when a package's packed tarball grows past the budget in tools/bundle-size.ts. +# On push to main: regenerate and commit the baseline so future PRs diff against +# the latest main. +on: + pull_request: + branches: [main] + paths: + - 'packages/appkit/**' + - 'packages/appkit-ui/**' + - 'packages/shared/**' + - 'bundle-size-baseline.json' + - 'tools/bundle-size.ts' + - '.github/workflows/bundle-size.yml' + push: + branches: [main] + paths: + - 'packages/appkit/**' + - 'packages/appkit-ui/**' + - 'packages/shared/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + report: + name: Report bundle size + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: write + runs-on: + group: databricks-protected-runner-group + labels: linux-ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup JFrog npm + uses: ./.github/actions/setup-jfrog-npm + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: 'pnpm' + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build packages + run: pnpm build + - name: Compare bundle size against baseline + id: size + run: pnpm exec tsx tools/bundle-size.ts --compare --markdown bundle-size-comment.md + + # Fork PRs get a read-only GITHUB_TOKEN, so commenting would 403 and the + # gate would surface a failure the author can't act on. Skip both for + # forks — the sizes are still printed in the job log above. + - name: Upsert bundle-size comment + if: github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + COMMENT_PATH: bundle-size-comment.md + with: + script: | + const upsert = require('./.github/scripts/upsert-bundle-size-comment.cjs'); + await upsert({ github, context }); + + - name: Fail if over budget + if: steps.size.outputs.exceeded == 'true' && github.event.pull_request.head.repo.full_name == github.repository + run: | + echo "::error::A package's packed tarball grew past the bundle-size budget. See the PR comment for details. Reduce the size, or acknowledge the increase by updating bundle-size-baseline.json." + exit 1 + + baseline: + name: Update baseline + if: github.event_name == 'push' + permissions: + contents: write + runs-on: + group: databricks-protected-runner-group + labels: linux-ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup JFrog npm + uses: ./.github/actions/setup-jfrog-npm + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: 'pnpm' + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build packages + run: pnpm build + - name: Regenerate baseline + run: pnpm exec tsx tools/bundle-size.ts --baseline + - name: Commit baseline if changed + run: | + if git diff --quiet bundle-size-baseline.json; then + echo "Baseline unchanged." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add bundle-size-baseline.json + git commit -s -m "chore: update bundle-size baseline [skip ci]" + git push diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json new file mode 100644 index 000000000..d10e11af0 --- /dev/null +++ b/bundle-size-baseline.json @@ -0,0 +1,62 @@ +{ + "packages": [ + { + "name": "@databricks/appkit", + "tarball": { + "packed": 761292, + "unpacked": 2795741 + }, + "dist": { + "raw": 2791325, + "gzip": 953653, + "fileCount": 667 + }, + "entries": [ + { + "id": ".", + "minified": 251286, + "gzip": 77538 + }, + { + "id": "./beta", + "minified": 121264, + "gzip": 38455 + } + ] + }, + { + "name": "@databricks/appkit-ui", + "tarball": { + "packed": 304085, + "unpacked": 1288694 + }, + "dist": { + "raw": 1284725, + "gzip": 424673, + "fileCount": 483 + }, + "entries": [ + { + "id": "./js", + "minified": 11848, + "gzip": 4155 + }, + { + "id": "./js/beta", + "minified": 0, + "gzip": 20 + }, + { + "id": "./react", + "minified": 182805, + "gzip": 47055 + }, + { + "id": "./react/beta", + "minified": 0, + "gzip": 20 + } + ] + } + ] +} diff --git a/package.json b/package.json index b4798edb6..3c31cd68b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "version": "0.0.2", "packageManager": "pnpm@10.21.0", "scripts": { - "build": "pnpm -r --filter=!docs build:package && pnpm sync:template && pnpm exec tsx tools/generate-plugin-doc-banners.ts", + "build": "pnpm -r --filter=!docs build:package && pnpm sync:template && pnpm exec tsx tools/generate-plugin-doc-banners.ts && pnpm exec tsx tools/bundle-size.ts", + "size": "tsx tools/bundle-size.ts", + "size:baseline": "tsx tools/bundle-size.ts --baseline", + "size:compare": "tsx tools/bundle-size.ts --compare", "sync:template": "appkit plugin sync --write --silent --plugins-dir packages/appkit/src/plugins --output template/appkit.plugins.json --require-plugins server", "build:watch": "pnpm -r --filter=!dev-playground --filter=!docs build:watch", "check:fix": "biome check --write .", diff --git a/tools/bundle-size.ts b/tools/bundle-size.ts new file mode 100644 index 000000000..c5725e4f4 --- /dev/null +++ b/tools/bundle-size.ts @@ -0,0 +1,392 @@ +#!/usr/bin/env tsx + +/** + * Measures and tracks the bundle size of the published packages (`appkit`, + * `appkit-ui`). Three metrics per package: + * + * 1. Tarball packed / unpacked size — what `npm publish` ships, via + * `npm pack --dry-run --json`. Dependencies stay external (matches how + * the packages build), so this is the size of our own shipped files. + * 2. `dist/` raw + gzip total — every built file summed. + * 3. Per-entry import cost — each entry point (`.`, `./react`, ...) bundled + * with esbuild, minified and gzipped, with dependencies kept external + * (`packages: "external"`). Reflects the reachable own-code cost per + * entry, consistent with deps being external/peer at publish time. + * + * Modes: + * (default) Measure both packages and print a console table. + * Appended to `pnpm build` so the report shows at the + * end of a build. Never exits non-zero. + * --baseline Measure and write `bundle-size-baseline.json`. + * --compare Measure, diff against the committed baseline, print + * a table. With --markdown also writes a PR + * comment body; writes `exceeded=` to + * $GITHUB_OUTPUT when set (gate handled by the caller). + * --json Also write the raw measurements as JSON. + * + * Run via `tsx tools/bundle-size.ts` (see the `size*` scripts in package.json). + */ + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { gzipSync } from "node:zlib"; +// esbuild ships as a transitive of the build toolchain (tsdown/rolldown) and is +// hoisted to the root node_modules (see .npmrc `public-hoist-pattern[]=*`), so +// it resolves here without a root devDependency. Declaring it directly would +// satisfy webpack's optional `esbuild` peer in the docs workspace and re-key the +// entire docusaurus lockfile graph — hence the intentional undeclared import. +import { build } from "esbuild"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, ".."); +const BASELINE_PATH = path.join(REPO_ROOT, "bundle-size-baseline.json"); + +type Platform = "node" | "browser"; + +interface PackageConfig { + name: string; + dir: string; + platform: Platform; + entries: { id: string; file: string }[]; +} + +const PACKAGES: PackageConfig[] = [ + { + name: "@databricks/appkit", + dir: "packages/appkit", + platform: "node", + entries: [ + { id: ".", file: "dist/index.js" }, + { id: "./beta", file: "dist/beta.js" }, + ], + }, + { + name: "@databricks/appkit-ui", + dir: "packages/appkit-ui", + platform: "browser", + entries: [ + { id: "./js", file: "dist/js/index.js" }, + { id: "./js/beta", file: "dist/js/beta.js" }, + { id: "./react", file: "dist/react/index.js" }, + { id: "./react/beta", file: "dist/react/beta.js" }, + ], + }, +]; + +// Fail the PR check only when a package's packed tarball grows past BOTH gates, +// so rounding noise never blocks a merge. +const BUDGET = { maxIncreasePct: 5, minIncreaseBytes: 10 * 1024 }; + +interface EntryMeasurement { + id: string; + minified: number | null; + gzip: number | null; +} + +interface PackageMeasurement { + name: string; + tarball: { packed: number; unpacked: number } | null; + dist: { raw: number; gzip: number; fileCount: number }; + entries: EntryMeasurement[]; +} + +function walkFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkFiles(full)); + else if (entry.isFile()) out.push(full); + } + return out; +} + +function measureDist(dir: string): PackageMeasurement["dist"] { + let raw = 0; + let gzip = 0; + let fileCount = 0; + for (const file of walkFiles(dir)) { + const contents = fs.readFileSync(file); + raw += contents.byteLength; + gzip += gzipSync(contents).byteLength; + fileCount += 1; + } + return { raw, gzip, fileCount }; +} + +function measureTarball(dir: string): PackageMeasurement["tarball"] { + try { + const stdout = execFileSync( + "npm", + ["pack", "--dry-run", "--json", "--ignore-scripts"], + { cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }, + ); + const parsed = JSON.parse(stdout)[0]; + return { packed: parsed.size, unpacked: parsed.unpackedSize }; + } catch { + return null; + } +} + +async function measureEntry( + absFile: string, + platform: Platform, +): Promise> { + if (!fs.existsSync(absFile)) return { minified: null, gzip: null }; + try { + const result = await build({ + entryPoints: [absFile], + bundle: true, + write: false, + minify: true, + format: "esm", + platform, + packages: "external", + legalComments: "none", + logLevel: "silent", + loader: { ".css": "empty" }, + }); + const js = + result.outputFiles.find((f) => f.path.endsWith(".js")) ?? + result.outputFiles[0]; + return { + minified: js.contents.byteLength, + gzip: gzipSync(js.contents).byteLength, + }; + } catch { + return { minified: null, gzip: null }; + } +} + +async function measurePackage(pkg: PackageConfig): Promise { + const dir = path.join(REPO_ROOT, pkg.dir); + const entries: EntryMeasurement[] = []; + for (const entry of pkg.entries) { + const cost = await measureEntry(path.join(dir, entry.file), pkg.platform); + entries.push({ id: entry.id, ...cost }); + } + return { + name: pkg.name, + tarball: measureTarball(dir), + dist: measureDist(path.join(dir, "dist")), + entries, + }; +} + +async function measureAll(): Promise { + const results: PackageMeasurement[] = []; + for (const pkg of PACKAGES) { + try { + results.push(await measurePackage(pkg)); + } catch (err) { + console.error(`bundle-size: failed to measure ${pkg.name}:`, err); + } + } + return results; +} + +function formatBytes(n: number | null | undefined): string { + if (n == null) return "—"; + const units = ["B", "KB", "MB"]; + let value = n; + let i = 0; + while (value >= 1024 && i < units.length - 1) { + value /= 1024; + i += 1; + } + return `${value.toFixed(i === 0 ? 0 : value < 10 ? 1 : 0)} ${units[i]}`; +} + +function formatDelta(current: number | null, base: number | null): string { + if (current == null || base == null) return "—"; + const delta = current - base; + if (delta === 0) return "no change"; + const pct = base === 0 ? 0 : (delta / base) * 100; + const sign = delta > 0 ? "+" : "-"; + return `${sign}${formatBytes(Math.abs(delta))} (${sign}${Math.abs(pct).toFixed(1)}%)`; +} + +/** True when the packed tarball grew past both budget gates. */ +function exceedsBudget(current: number | null, base: number | null): boolean { + if (current == null || base == null) return false; + const delta = current - base; + if (delta <= 0) return false; + const pct = base === 0 ? 100 : (delta / base) * 100; + return pct > BUDGET.maxIncreasePct && delta > BUDGET.minIncreaseBytes; +} + +function printTable( + results: PackageMeasurement[], + baseline: BaselineFile | null, +) { + console.log("\n📦 Bundle size\n"); + for (const pkg of results) { + const base = baseline?.packages.find((p) => p.name === pkg.name) ?? null; + console.log(` ${pkg.name}`); + const rows: [string, number | null, number | null][] = [ + [ + "tarball packed", + pkg.tarball?.packed ?? null, + base?.tarball?.packed ?? null, + ], + [ + "tarball unpacked", + pkg.tarball?.unpacked ?? null, + base?.tarball?.unpacked ?? null, + ], + ["dist raw", pkg.dist.raw, base?.dist.raw ?? null], + ["dist gzip", pkg.dist.gzip, base?.dist.gzip ?? null], + ]; + for (const [label, cur, prev] of rows) { + const delta = baseline ? ` ${formatDelta(cur, prev)}` : ""; + console.log( + ` ${label.padEnd(18)} ${formatBytes(cur).padStart(9)}${delta}`, + ); + } + for (const entry of pkg.entries) { + const prev = base?.entries.find((e) => e.id === entry.id); + const delta = baseline + ? ` ${formatDelta(entry.gzip, prev?.gzip ?? null)}` + : ""; + console.log( + ` entry ${entry.id.padEnd(12)} ${formatBytes(entry.gzip).padStart(9)} gz${delta}`, + ); + } + console.log(""); + } +} + +interface BaselineFile { + packages: PackageMeasurement[]; +} + +function loadBaseline(): BaselineFile | null { + if (!fs.existsSync(BASELINE_PATH)) return null; + try { + return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf-8")); + } catch { + return null; + } +} + +function renderMarkdown( + results: PackageMeasurement[], + baseline: BaselineFile | null, +): { body: string; exceeded: boolean } { + const MARKER = ""; + const lines: string[] = [MARKER, "## 📦 Bundle size report", ""]; + let exceeded = false; + + if (baseline) { + lines.push("Compared against `bundle-size-baseline.json` (main).", ""); + } else { + lines.push( + "> No committed baseline found — showing current sizes only. Deltas appear once `bundle-size-baseline.json` lands on `main`.", + "", + ); + } + + for (const pkg of results) { + const base = baseline?.packages.find((p) => p.name === pkg.name) ?? null; + const pkgExceeded = exceedsBudget( + pkg.tarball?.packed ?? null, + base?.tarball?.packed ?? null, + ); + exceeded = exceeded || pkgExceeded; + + lines.push(`### \`${pkg.name}\`${pkgExceeded ? " ⚠️ over budget" : ""}`, ""); + lines.push("| Metric | main | this PR | Δ |", "| --- | --- | --- | --- |"); + const rows: [string, number | null, number | null][] = [ + [ + "Tarball (packed)", + pkg.tarball?.packed ?? null, + base?.tarball?.packed ?? null, + ], + [ + "Tarball (unpacked)", + pkg.tarball?.unpacked ?? null, + base?.tarball?.unpacked ?? null, + ], + ["dist (raw)", pkg.dist.raw, base?.dist.raw ?? null], + ["dist (gzip)", pkg.dist.gzip, base?.dist.gzip ?? null], + ]; + for (const [label, cur, prev] of rows) { + lines.push( + `| ${label} | ${formatBytes(prev)} | ${formatBytes(cur)} | ${baseline ? formatDelta(cur, prev) : "—"} |`, + ); + } + lines.push(""); + lines.push( + "
Per-entry import cost (minified + gzip, deps external)", + "", + "| Entry | main (gz) | this PR (gz) | Δ gz |", + "| --- | --- | --- | --- |", + ); + for (const entry of pkg.entries) { + const prev = base?.entries.find((e) => e.id === entry.id) ?? null; + lines.push( + `| \`${entry.id}\` | ${formatBytes(prev?.gzip ?? null)} | ${formatBytes(entry.gzip)} | ${baseline ? formatDelta(entry.gzip, prev?.gzip ?? null) : "—"} |`, + ); + } + lines.push("", "
", ""); + } + + if (exceeded) { + lines.push( + `> ⚠️ A package's packed tarball grew by more than **${BUDGET.maxIncreasePct}%** (and >${formatBytes(BUDGET.minIncreaseBytes)}). This check will fail — reduce the size or acknowledge the increase by updating \`bundle-size-baseline.json\`.`, + ); + } + + return { body: lines.join("\n"), exceeded }; +} + +function writeGithubOutput(key: string, value: string) { + const outFile = process.env.GITHUB_OUTPUT; + if (!outFile) return; + fs.appendFileSync(outFile, `${key}=${value}\n`); +} + +async function main() { + const { values } = parseArgs({ + options: { + baseline: { type: "boolean", default: false }, + compare: { type: "boolean", default: false }, + markdown: { type: "string" }, + json: { type: "string" }, + }, + }); + + const results = await measureAll(); + + if (values.json) { + fs.writeFileSync(values.json, JSON.stringify(results, null, 2)); + } + + if (values.baseline) { + const baseline: BaselineFile = { packages: results }; + fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`); + console.log(`Wrote baseline to ${path.relative(REPO_ROOT, BASELINE_PATH)}`); + return; + } + + if (values.compare) { + const baseline = loadBaseline(); + printTable(results, baseline); + const { body, exceeded } = renderMarkdown(results, baseline); + if (values.markdown) fs.writeFileSync(values.markdown, `${body}\n`); + writeGithubOutput("exceeded", String(exceeded)); + return; + } + + // Default: local report at the end of a build. Show deltas if a baseline + // exists, but never fail the build. + printTable(results, loadBaseline()); +} + +main().catch((err) => { + // Default/measure path must not break `pnpm build`. + console.error("bundle-size:", err); +}); From ad0d5eea9cc10ced7a045fa74aba80c0c224c559 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 1 Jul 2026 18:15:39 +0200 Subject: [PATCH 2/7] ci: grant id-token permission for JFrog OIDC in bundle-size jobs Signed-off-by: MarioCadenas --- .github/workflows/bundle-size.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index c069dabdc..c5c11c2a2 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -36,6 +36,7 @@ jobs: permissions: contents: read pull-requests: write + id-token: write # setup-jfrog-npm exchanges an OIDC token for registry auth runs-on: group: databricks-protected-runner-group labels: linux-ubuntu-latest @@ -80,6 +81,7 @@ jobs: if: github.event_name == 'push' permissions: contents: write + id-token: write # setup-jfrog-npm exchanges an OIDC token for registry auth runs-on: group: databricks-protected-runner-group labels: linux-ubuntu-latest From 907a4d7683192fe1bf0e343bfd38d97495427111 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 1 Jul 2026 18:20:15 +0200 Subject: [PATCH 3/7] chore: seed bundle-size baseline from clean build Signed-off-by: MarioCadenas --- bundle-size-baseline.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index d10e11af0..e9dacc0c6 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,13 +3,13 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 761292, - "unpacked": 2795741 + "packed": 678473, + "unpacked": 2398820 }, "dist": { - "raw": 2791325, - "gzip": 953653, - "fileCount": 667 + "raw": 2394404, + "gzip": 802685, + "fileCount": 522 }, "entries": [ { @@ -27,13 +27,13 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 304085, - "unpacked": 1288694 + "packed": 303755, + "unpacked": 1288650 }, "dist": { - "raw": 1284725, - "gzip": 424673, - "fileCount": 483 + "raw": 1284681, + "gzip": 424469, + "fileCount": 475 }, "entries": [ { From 4d5e1c79f9138ee09b01b26a9471e0062e57f6c6 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 1 Jul 2026 18:35:10 +0200 Subject: [PATCH 4/7] ci: add per-entry composition breakdown to bundle-size report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsed per-entry section from esbuild's metafile (code-splitting on): own-code size, initial vs lazy-loaded chunks, and — for browser packages whose deps are bundleable — the node_modules weight a consumer pays (peerDeps external). Node packages keep deps external, so node_modules reads "external". Composition runs only in --baseline/--compare, keeping the local build report fast. Signed-off-by: MarioCadenas --- bundle-size-baseline.json | 64 ++++++++++-- tools/bundle-size.ts | 209 ++++++++++++++++++++++++++++++++------ 2 files changed, 236 insertions(+), 37 deletions(-) diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index e9dacc0c6..3cff0e981 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -14,13 +14,29 @@ "entries": [ { "id": ".", - "minified": 251286, - "gzip": 77538 + "minified": 250758, + "gzip": 78284, + "composition": { + "own": 249483, + "initialGzip": 74186, + "lazyGzip": 4098, + "lazyChunks": 1, + "nodeModules": null, + "bundledGzip": null + } }, { "id": "./beta", - "minified": 121264, - "gzip": 38455 + "minified": 120500, + "gzip": 39881, + "composition": { + "own": 119365, + "initialGzip": 30732, + "lazyGzip": 9149, + "lazyChunks": 3, + "nodeModules": null, + "bundledGzip": null + } } ] }, @@ -39,22 +55,54 @@ { "id": "./js", "minified": 11848, - "gzip": 4155 + "gzip": 4155, + "composition": { + "own": 11574, + "initialGzip": 4155, + "lazyGzip": 0, + "lazyChunks": 0, + "nodeModules": 213288, + "bundledGzip": 54896 + } }, { "id": "./js/beta", "minified": 0, - "gzip": 20 + "gzip": 20, + "composition": { + "own": 0, + "initialGzip": 20, + "lazyGzip": 0, + "lazyChunks": 0, + "nodeModules": 0, + "bundledGzip": 20 + } }, { "id": "./react", "minified": 182805, - "gzip": 47055 + "gzip": 47055, + "composition": { + "own": 175304, + "initialGzip": 47055, + "lazyGzip": 0, + "lazyChunks": 0, + "nodeModules": 1909652, + "bundledGzip": 655106 + } }, { "id": "./react/beta", "minified": 0, - "gzip": 20 + "gzip": 20, + "composition": { + "own": 0, + "initialGzip": 20, + "lazyGzip": 0, + "lazyChunks": 0, + "nodeModules": 0, + "bundledGzip": 20 + } } ] } diff --git a/tools/bundle-size.ts b/tools/bundle-size.ts index c5725e4f4..8baf0276d 100644 --- a/tools/bundle-size.ts +++ b/tools/bundle-size.ts @@ -12,6 +12,13 @@ * with esbuild, minified and gzipped, with dependencies kept external * (`packages: "external"`). Reflects the reachable own-code cost per * entry, consistent with deps being external/peer at publish time. + * 4. Per-entry composition (deep modes only, collapsed in the PR comment) — + * from esbuild's metafile with code-splitting on: own-code size, initial + * vs lazy-loaded chunks, and — for browser packages whose deps can be + * bundled — the node_modules weight a consumer would pay (peerDeps stay + * external). Node packages keep deps external (native addons can't bundle + * and a server SDK never ships deps to a browser), so their node_modules + * column reads "external". * * Modes: * (default) Measure both packages and print a console table. @@ -50,6 +57,10 @@ interface PackageConfig { name: string; dir: string; platform: Platform; + // When true, the composition pass bundles dependencies (except peerDeps) to + // reveal node_modules weight. Only sound for browser packages: node packages + // have native deps that can't bundle and never ship deps to a browser. + bundleDeps: boolean; entries: { id: string; file: string }[]; } @@ -58,6 +69,7 @@ const PACKAGES: PackageConfig[] = [ name: "@databricks/appkit", dir: "packages/appkit", platform: "node", + bundleDeps: false, entries: [ { id: ".", file: "dist/index.js" }, { id: "./beta", file: "dist/beta.js" }, @@ -67,6 +79,7 @@ const PACKAGES: PackageConfig[] = [ name: "@databricks/appkit-ui", dir: "packages/appkit-ui", platform: "browser", + bundleDeps: true, entries: [ { id: "./js", file: "dist/js/index.js" }, { id: "./js/beta", file: "dist/js/beta.js" }, @@ -80,10 +93,22 @@ const PACKAGES: PackageConfig[] = [ // so rounding noise never blocks a merge. const BUDGET = { maxIncreasePct: 5, minIncreaseBytes: 10 * 1024 }; +interface Composition { + own: number; // own-code minified bytes (source-attributed, deps excluded) + initialGzip: number; // entry chunk gzip + lazyGzip: number; // sum of dynamically-imported (lazy) chunk gzip + lazyChunks: number; // number of lazy chunks + // Deps-bundled view: what a consumer pays with node_modules bundled in + // (peerDeps still external). Null when deps stay external (node packages). + nodeModules: number | null; // node_modules minified bytes + bundledGzip: number | null; // total gzip with deps bundled +} + interface EntryMeasurement { id: string; - minified: number | null; - gzip: number | null; + minified: number | null; // total minified (own code, deps external) + gzip: number | null; // total gzip (own code, deps external) — headline import cost + composition?: Composition; } interface PackageMeasurement { @@ -131,41 +156,132 @@ function measureTarball(dir: string): PackageMeasurement["tarball"] { } } +interface Analysis { + totalMinified: number; + totalGzip: number; + own: number; // minified bytes attributed to non-node_modules inputs + nodeModules: number; // minified bytes attributed to node_modules inputs + initialGzip: number; // entry chunk gzip + lazyGzip: number; // sum of non-entry (lazy) chunk gzip + lazyChunks: number; +} + +/** + * Bundle a single entry with code-splitting and read esbuild's metafile to + * attribute output bytes to own-code vs node_modules and to initial vs lazy + * chunks. When `external` is given, only those specifiers stay external (deps + * are bundled); otherwise all bare imports are external (`packages: "external"`). + */ +async function buildAndAnalyze( + absFile: string, + platform: Platform, + external?: string[], +): Promise { + const result = await build({ + entryPoints: [absFile], + bundle: true, + write: false, + minify: true, + splitting: true, + format: "esm", + platform, + metafile: true, + legalComments: "none", + logLevel: "silent", + outdir: "__bundle_size__", // naming only; write:false emits nothing to disk + loader: { ".css": "empty" }, + ...(external ? { external } : { packages: "external" }), + }); + const meta = result.metafile; + const a: Analysis = { + totalMinified: 0, + totalGzip: 0, + own: 0, + nodeModules: 0, + initialGzip: 0, + lazyGzip: 0, + lazyChunks: 0, + }; + for (const [outPath, out] of Object.entries(meta.outputs)) { + if (!outPath.endsWith(".js")) continue; + a.totalMinified += out.bytes; + for (const [input, info] of Object.entries(out.inputs)) { + if (input.includes("node_modules")) a.nodeModules += info.bytesInOutput; + else a.own += info.bytesInOutput; + } + const file = result.outputFiles.find((f) => f.path.endsWith(outPath)); + const gzip = file ? gzipSync(file.contents).byteLength : 0; + a.totalGzip += gzip; + if (out.entryPoint) a.initialGzip += gzip; + else { + a.lazyGzip += gzip; + a.lazyChunks += 1; + } + } + return a; +} + async function measureEntry( absFile: string, platform: Platform, + deep: boolean, + bundleExternal: string[] | null, ): Promise> { if (!fs.existsSync(absFile)) return { minified: null, gzip: null }; try { - const result = await build({ - entryPoints: [absFile], - bundle: true, - write: false, - minify: true, - format: "esm", - platform, - packages: "external", - legalComments: "none", - logLevel: "silent", - loader: { ".css": "empty" }, - }); - const js = - result.outputFiles.find((f) => f.path.endsWith(".js")) ?? - result.outputFiles[0]; - return { - minified: js.contents.byteLength, - gzip: gzipSync(js.contents).byteLength, + // Own-code cost (deps external) — the headline, stable import cost. + const own = await buildAndAnalyze(absFile, platform); + const composition: Composition = { + own: own.own, + initialGzip: own.initialGzip, + lazyGzip: own.lazyGzip, + lazyChunks: own.lazyChunks, + nodeModules: null, + bundledGzip: null, }; + // Deps-bundled view (browser packages, deep modes only). Best-effort: if a + // dep can't be bundled, leave the node_modules columns empty. + if (deep && bundleExternal) { + try { + const bundled = await buildAndAnalyze( + absFile, + platform, + bundleExternal, + ); + composition.nodeModules = bundled.nodeModules; + composition.bundledGzip = bundled.totalGzip; + } catch { + // leave nodeModules/bundledGzip null + } + } + return { minified: own.totalMinified, gzip: own.totalGzip, composition }; } catch { return { minified: null, gzip: null }; } } -async function measurePackage(pkg: PackageConfig): Promise { +/** peerDependencies stay external in the consumer-cost view (consumer provides them). */ +function peerExternals(dir: string): string[] { + const pkg = JSON.parse( + fs.readFileSync(path.join(dir, "package.json"), "utf-8"), + ); + return Object.keys(pkg.peerDependencies ?? {}); +} + +async function measurePackage( + pkg: PackageConfig, + deep: boolean, +): Promise { const dir = path.join(REPO_ROOT, pkg.dir); + const bundleExternal = pkg.bundleDeps ? peerExternals(dir) : null; const entries: EntryMeasurement[] = []; for (const entry of pkg.entries) { - const cost = await measureEntry(path.join(dir, entry.file), pkg.platform); + const cost = await measureEntry( + path.join(dir, entry.file), + pkg.platform, + deep, + bundleExternal, + ); entries.push({ id: entry.id, ...cost }); } return { @@ -176,11 +292,11 @@ async function measurePackage(pkg: PackageConfig): Promise { }; } -async function measureAll(): Promise { +async function measureAll(deep: boolean): Promise { const results: PackageMeasurement[] = []; for (const pkg of PACKAGES) { try { - results.push(await measurePackage(pkg)); + results.push(await measurePackage(pkg, deep)); } catch (err) { console.error(`bundle-size: failed to measure ${pkg.name}:`, err); } @@ -209,6 +325,19 @@ function formatDelta(current: number | null, base: number | null): string { return `${sign}${formatBytes(Math.abs(delta))} (${sign}${Math.abs(pct).toFixed(1)}%)`; } +/** A value cell with an optional compact signed delta, e.g. "166 KB (+2 KB)". */ +function cell( + current: number | null, + base: number | null, + hasBaseline: boolean, +): string { + const value = formatBytes(current); + if (!hasBaseline || current == null || base == null) return value; + const delta = current - base; + if (delta === 0) return value; + return `${value} (${delta > 0 ? "+" : "-"}${formatBytes(Math.abs(delta))})`; +} + /** True when the packed tarball grew past both budget gates. */ function exceedsBudget(current: number | null, base: number | null): boolean { if (current == null || base == null) return false; @@ -319,16 +448,35 @@ function renderMarkdown( ); } lines.push(""); + const bundleDeps = PACKAGES.find((p) => p.name === pkg.name)?.bundleDeps; lines.push( - "
Per-entry import cost (minified + gzip, deps external)", + "
Per-entry composition — own code (deps external) + lazy chunks; node_modules = weight if bundled", "", - "| Entry | main (gz) | this PR (gz) | Δ gz |", - "| --- | --- | --- | --- |", + "| Entry | Import cost (gz) | Own code (min) | Lazy (gz) | node_modules (min) | + deps total (gz) |", + "| --- | --- | --- | --- | --- | --- |", ); for (const entry of pkg.entries) { const prev = base?.entries.find((e) => e.id === entry.id) ?? null; + const c = entry.composition; + const pc = prev?.composition ?? null; + const lazy = + c && c.lazyChunks > 0 + ? `${formatBytes(c.lazyGzip)} · ${c.lazyChunks} chunk${c.lazyChunks > 1 ? "s" : ""}` + : "none"; + const nodeModules = + c?.nodeModules != null + ? cell(c.nodeModules, pc?.nodeModules ?? null, Boolean(baseline)) + : bundleDeps + ? "n/a" + : "external"; + const bundled = + c?.bundledGzip != null + ? formatBytes(c.bundledGzip) + : bundleDeps + ? "n/a" + : "—"; lines.push( - `| \`${entry.id}\` | ${formatBytes(prev?.gzip ?? null)} | ${formatBytes(entry.gzip)} | ${baseline ? formatDelta(entry.gzip, prev?.gzip ?? null) : "—"} |`, + `| \`${entry.id}\` | ${cell(entry.gzip, prev?.gzip ?? null, Boolean(baseline))} | ${cell(c?.own ?? null, pc?.own ?? null, Boolean(baseline))} | ${lazy} | ${nodeModules} | ${bundled} |`, ); } lines.push("", "
", ""); @@ -359,7 +507,10 @@ async function main() { }, }); - const results = await measureAll(); + // The composition pass (bundling deps for browser packages) is only needed + // for the baseline and the PR comparison, not the fast local build report. + const deep = Boolean(values.baseline || values.compare); + const results = await measureAll(deep); if (values.json) { fs.writeFileSync(values.json, JSON.stringify(results, null, 2)); From a45c82debb19029eabf7a90bd16193e5d30ddf40 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 1 Jul 2026 18:47:08 +0200 Subject: [PATCH 5/7] ci: list individual lazy chunks in bundle-size composition Each lazy chunk is now listed with its own gzip size (labeled by its largest input module/dep), not just an aggregate count. Browser packages read their lazy chunks from the deps-bundled build, so appkit-ui no longer shows "none". Signed-off-by: MarioCadenas --- bundle-size-baseline.json | 46 ++++++++++++++++++++++++++----- tools/bundle-size.ts | 57 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index 3cff0e981..51f44a004 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -21,6 +21,12 @@ "initialGzip": 74186, "lazyGzip": 4098, "lazyChunks": 1, + "lazyChunkList": [ + { + "label": "utils.js", + "gzip": 4098 + } + ], "nodeModules": null, "bundledGzip": null } @@ -34,6 +40,20 @@ "initialGzip": 30732, "lazyGzip": 9149, "lazyChunks": 3, + "lazyChunkList": [ + { + "label": "databricks.js", + "gzip": 5807 + }, + { + "label": "service-context.js", + "gzip": 3123 + }, + { + "label": "client-options.js", + "gzip": 219 + } + ], "nodeModules": null, "bundledGzip": null } @@ -43,12 +63,12 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 303755, + "packed": 303759, "unpacked": 1288650 }, "dist": { "raw": 1284681, - "gzip": 424469, + "gzip": 424471, "fileCount": 475 }, "entries": [ @@ -59,8 +79,14 @@ "composition": { "own": 11574, "initialGzip": 4155, - "lazyGzip": 0, - "lazyChunks": 0, + "lazyGzip": 120, + "lazyChunks": 1, + "lazyChunkList": [ + { + "label": "chunk", + "gzip": 120 + } + ], "nodeModules": 213288, "bundledGzip": 54896 } @@ -74,6 +100,7 @@ "initialGzip": 20, "lazyGzip": 0, "lazyChunks": 0, + "lazyChunkList": [], "nodeModules": 0, "bundledGzip": 20 } @@ -85,8 +112,14 @@ "composition": { "own": 175304, "initialGzip": 47055, - "lazyGzip": 0, - "lazyChunks": 0, + "lazyGzip": 2150, + "lazyChunks": 1, + "lazyChunkList": [ + { + "label": "tslib", + "gzip": 2150 + } + ], "nodeModules": 1909652, "bundledGzip": 655106 } @@ -100,6 +133,7 @@ "initialGzip": 20, "lazyGzip": 0, "lazyChunks": 0, + "lazyChunkList": [], "nodeModules": 0, "bundledGzip": 20 } diff --git a/tools/bundle-size.ts b/tools/bundle-size.ts index 8baf0276d..34540571c 100644 --- a/tools/bundle-size.ts +++ b/tools/bundle-size.ts @@ -93,11 +93,17 @@ const PACKAGES: PackageConfig[] = [ // so rounding noise never blocks a merge. const BUDGET = { maxIncreasePct: 5, minIncreaseBytes: 10 * 1024 }; +interface ChunkInfo { + label: string; // derived from the chunk's largest input (module or dep name) + gzip: number; +} + interface Composition { own: number; // own-code minified bytes (source-attributed, deps excluded) initialGzip: number; // entry chunk gzip lazyGzip: number; // sum of dynamically-imported (lazy) chunk gzip lazyChunks: number; // number of lazy chunks + lazyChunkList: ChunkInfo[]; // each lazy chunk, largest first // Deps-bundled view: what a consumer pays with node_modules bundled in // (peerDeps still external). Null when deps stay external (node packages). nodeModules: number | null; // node_modules minified bytes @@ -164,6 +170,27 @@ interface Analysis { initialGzip: number; // entry chunk gzip lazyGzip: number; // sum of non-entry (lazy) chunk gzip lazyChunks: number; + lazyChunkList: ChunkInfo[]; +} + +/** Name a chunk after its largest input module (or dependency package). */ +function chunkLabel(inputs: Record): string { + let best = ""; + let max = -1; + for (const [input, info] of Object.entries(inputs)) { + if (info.bytesInOutput > max) { + max = info.bytesInOutput; + best = input; + } + } + if (!best) return "chunk"; + const marker = "node_modules/"; + const nm = best.lastIndexOf(marker); + if (nm !== -1) { + const parts = best.slice(nm + marker.length).split("/"); + return parts[0].startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0]; + } + return path.basename(best); } /** @@ -201,6 +228,7 @@ async function buildAndAnalyze( initialGzip: 0, lazyGzip: 0, lazyChunks: 0, + lazyChunkList: [], }; for (const [outPath, out] of Object.entries(meta.outputs)) { if (!outPath.endsWith(".js")) continue; @@ -216,8 +244,12 @@ async function buildAndAnalyze( else { a.lazyGzip += gzip; a.lazyChunks += 1; + a.lazyChunkList.push({ label: chunkLabel(out.inputs), gzip }); } } + a.lazyChunkList.sort( + (x, y) => y.gzip - x.gzip || x.label.localeCompare(y.label), + ); return a; } @@ -236,11 +268,15 @@ async function measureEntry( initialGzip: own.initialGzip, lazyGzip: own.lazyGzip, lazyChunks: own.lazyChunks, + lazyChunkList: own.lazyChunkList, nodeModules: null, bundledGzip: null, }; // Deps-bundled view (browser packages, deep modes only). Best-effort: if a - // dep can't be bundled, leave the node_modules columns empty. + // dep can't be bundled, leave the node_modules columns empty. Browser + // packages lazy-load dependencies (e.g. apache-arrow), not their own + // modules, so the meaningful lazy chunks only appear once deps are bundled — + // take the lazy split and chunk list from this build too. if (deep && bundleExternal) { try { const bundled = await buildAndAnalyze( @@ -250,8 +286,11 @@ async function measureEntry( ); composition.nodeModules = bundled.nodeModules; composition.bundledGzip = bundled.totalGzip; + composition.lazyGzip = bundled.lazyGzip; + composition.lazyChunks = bundled.lazyChunks; + composition.lazyChunkList = bundled.lazyChunkList; } catch { - // leave nodeModules/bundledGzip null + // leave deps-bundled fields at their own-code defaults } } return { minified: own.totalMinified, gzip: own.totalGzip, composition }; @@ -479,6 +518,20 @@ function renderMarkdown( `| \`${entry.id}\` | ${cell(entry.gzip, prev?.gzip ?? null, Boolean(baseline))} | ${cell(c?.own ?? null, pc?.own ?? null, Boolean(baseline))} | ${lazy} | ${nodeModules} | ${bundled} |`, ); } + // List the individual lazy chunks (gzip) under the table. + const withChunks = pkg.entries.filter( + (e) => (e.composition?.lazyChunkList?.length ?? 0) > 0, + ); + if (withChunks.length > 0) { + lines.push("", "**Lazy chunks (gzip):**"); + for (const entry of withChunks) { + for (const chunk of entry.composition?.lazyChunkList ?? []) { + lines.push( + `- \`${entry.id}\` → \`${chunk.label}\` ${formatBytes(chunk.gzip)}`, + ); + } + } + } lines.push("", "
", ""); } From 64680d0b1e2397b8d76c4a56cee307456d0d88c6 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 1 Jul 2026 19:02:38 +0200 Subject: [PATCH 6/7] ci: classify chunks by reachability; show initial/lazy split and full chunk table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix: esbuild sets entryPoint on dynamic-import chunks too, so the previous "has entryPoint => initial" rule miscounted lazy chunks (e.g. apache-arrow) as initial. Now classify by the static-import closure from the entry — anything reachable only via a dynamic import is lazy. The composition table shows Initial/Lazy/Total, and a new Chunks table lists every emitted chunk with its load type. Browser packages measure the consumer bundle (deps bundled, peerDeps external) so lazy-loaded deps like apache-arrow surface correctly. Signed-off-by: MarioCadenas --- bundle-size-baseline.json | 145 ++++++++++++++++-------- tools/bundle-size.ts | 224 ++++++++++++++++++++------------------ 2 files changed, 220 insertions(+), 149 deletions(-) diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index 51f44a004..1ce50fd59 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -17,18 +17,28 @@ "minified": 250758, "gzip": 78284, "composition": { + "initialGzip": 75710, + "lazyGzip": 2574, + "totalGzip": 78284, "own": 249483, - "initialGzip": 74186, - "lazyGzip": 4098, - "lazyChunks": 1, - "lazyChunkList": [ + "nodeModules": null, + "chunks": [ + { + "label": "index.js", + "gzip": 71612, + "kind": "initial" + }, { "label": "utils.js", - "gzip": 4098 + "gzip": 4098, + "kind": "initial" + }, + { + "label": "remote-tunnel-manager.js", + "gzip": 2574, + "kind": "lazy" } - ], - "nodeModules": null, - "bundledGzip": null + ] } }, { @@ -36,26 +46,43 @@ "minified": 120500, "gzip": 39881, "composition": { + "initialGzip": 39650, + "lazyGzip": 231, + "totalGzip": 39881, "own": 119365, - "initialGzip": 30732, - "lazyGzip": 9149, - "lazyChunks": 3, - "lazyChunkList": [ + "nodeModules": null, + "chunks": [ + { + "label": "beta.js", + "gzip": 30501, + "kind": "initial" + }, { "label": "databricks.js", - "gzip": 5807 + "gzip": 5807, + "kind": "initial" }, { "label": "service-context.js", - "gzip": 3123 + "gzip": 3123, + "kind": "initial" }, { "label": "client-options.js", - "gzip": 219 + "gzip": 219, + "kind": "initial" + }, + { + "label": "databricks.js", + "gzip": 128, + "kind": "lazy" + }, + { + "label": "index.js", + "gzip": 103, + "kind": "lazy" } - ], - "nodeModules": null, - "bundledGzip": null + ] } } ] @@ -77,18 +104,28 @@ "minified": 11848, "gzip": 4155, "composition": { - "own": 11574, - "initialGzip": 4155, - "lazyGzip": 120, - "lazyChunks": 1, - "lazyChunkList": [ + "initialGzip": 4309, + "lazyGzip": 50587, + "totalGzip": 54896, + "own": 11585, + "nodeModules": 213288, + "chunks": [ + { + "label": "index.js", + "gzip": 4189, + "kind": "initial" + }, { "label": "chunk", - "gzip": 120 + "gzip": 120, + "kind": "initial" + }, + { + "label": "apache-arrow", + "gzip": 50587, + "kind": "lazy" } - ], - "nodeModules": 213288, - "bundledGzip": 54896 + ] } }, { @@ -96,13 +133,18 @@ "minified": 0, "gzip": 20, "composition": { - "own": 0, "initialGzip": 20, "lazyGzip": 0, - "lazyChunks": 0, - "lazyChunkList": [], + "totalGzip": 20, + "own": 0, "nodeModules": 0, - "bundledGzip": 20 + "chunks": [ + { + "label": "beta.js", + "gzip": 20, + "kind": "initial" + } + ] } }, { @@ -110,18 +152,28 @@ "minified": 182805, "gzip": 47055, "composition": { - "own": 175304, - "initialGzip": 47055, - "lazyGzip": 2150, - "lazyChunks": 1, - "lazyChunkList": [ + "initialGzip": 605334, + "lazyGzip": 49772, + "totalGzip": 655106, + "own": 170537, + "nodeModules": 1909652, + "chunks": [ + { + "label": "index.js", + "gzip": 603184, + "kind": "initial" + }, { "label": "tslib", - "gzip": 2150 + "gzip": 2150, + "kind": "initial" + }, + { + "label": "apache-arrow", + "gzip": 49772, + "kind": "lazy" } - ], - "nodeModules": 1909652, - "bundledGzip": 655106 + ] } }, { @@ -129,13 +181,18 @@ "minified": 0, "gzip": 20, "composition": { - "own": 0, "initialGzip": 20, "lazyGzip": 0, - "lazyChunks": 0, - "lazyChunkList": [], + "totalGzip": 20, + "own": 0, "nodeModules": 0, - "bundledGzip": 20 + "chunks": [ + { + "label": "beta.js", + "gzip": 20, + "kind": "initial" + } + ] } } ] diff --git a/tools/bundle-size.ts b/tools/bundle-size.ts index 34540571c..da06d4e16 100644 --- a/tools/bundle-size.ts +++ b/tools/bundle-size.ts @@ -13,12 +13,13 @@ * (`packages: "external"`). Reflects the reachable own-code cost per * entry, consistent with deps being external/peer at publish time. * 4. Per-entry composition (deep modes only, collapsed in the PR comment) — - * from esbuild's metafile with code-splitting on: own-code size, initial - * vs lazy-loaded chunks, and — for browser packages whose deps can be - * bundled — the node_modules weight a consumer would pay (peerDeps stay - * external). Node packages keep deps external (native addons can't bundle - * and a server SDK never ships deps to a browser), so their node_modules - * column reads "external". + * from esbuild's metafile with code-splitting on: initial (statically + * reachable) vs lazy (dynamic-import-only, e.g. apache-arrow) payload, + * node_modules weight, own-code size, and a table of every emitted chunk. + * Browser packages bundle deps (peerDeps external) so this is the real + * consumer bundle; node packages keep deps external (native addons can't + * bundle, and a server SDK never ships deps to a browser), so their + * node_modules column reads "external". * * Modes: * (default) Measure both packages and print a console table. @@ -94,20 +95,20 @@ const PACKAGES: PackageConfig[] = [ const BUDGET = { maxIncreasePct: 5, minIncreaseBytes: 10 * 1024 }; interface ChunkInfo { - label: string; // derived from the chunk's largest input (module or dep name) + label: string; // the chunk's own entry module, or its largest input gzip: number; + kind: "initial" | "lazy"; // initial = statically reachable from the entry } interface Composition { - own: number; // own-code minified bytes (source-attributed, deps excluded) - initialGzip: number; // entry chunk gzip - lazyGzip: number; // sum of dynamically-imported (lazy) chunk gzip - lazyChunks: number; // number of lazy chunks - lazyChunkList: ChunkInfo[]; // each lazy chunk, largest first - // Deps-bundled view: what a consumer pays with node_modules bundled in - // (peerDeps still external). Null when deps stay external (node packages). - nodeModules: number | null; // node_modules minified bytes - bundledGzip: number | null; // total gzip with deps bundled + // For browser packages this is the consumer bundle (deps bundled, peerDeps + // external); for node packages it is own code with deps external (as shipped). + initialGzip: number; // initial (static-import) payload gzip + lazyGzip: number; // lazy (dynamic-import) payload gzip + totalGzip: number; // initial + lazy + own: number; // own-code minified bytes (deps excluded) + nodeModules: number | null; // deps minified; null when deps external (node pkgs) + chunks: ChunkInfo[]; // every emitted chunk, initial first then largest-first } interface EntryMeasurement { @@ -167,30 +168,31 @@ interface Analysis { totalGzip: number; own: number; // minified bytes attributed to non-node_modules inputs nodeModules: number; // minified bytes attributed to node_modules inputs - initialGzip: number; // entry chunk gzip - lazyGzip: number; // sum of non-entry (lazy) chunk gzip - lazyChunks: number; - lazyChunkList: ChunkInfo[]; + initialGzip: number; // initial (static-import closure) chunk gzip + lazyGzip: number; // lazy (dynamic-import-only) chunk gzip + chunks: ChunkInfo[]; } -/** Name a chunk after its largest input module (or dependency package). */ -function chunkLabel(inputs: Record): string { - let best = ""; - let max = -1; - for (const [input, info] of Object.entries(inputs)) { - if (info.bytesInOutput > max) { - max = info.bytesInOutput; - best = input; - } - } - if (!best) return "chunk"; +type MetaOutput = { + entryPoint?: string; + inputs: Record; +}; + +/** Name a chunk after its own entry module, else its largest input. */ +function chunkLabel(out: MetaOutput): string { + const source = + out.entryPoint ?? + Object.entries(out.inputs).sort( + (a, b) => b[1].bytesInOutput - a[1].bytesInOutput, + )[0]?.[0]; + if (!source) return "chunk"; const marker = "node_modules/"; - const nm = best.lastIndexOf(marker); + const nm = source.lastIndexOf(marker); if (nm !== -1) { - const parts = best.slice(nm + marker.length).split("/"); + const parts = source.slice(nm + marker.length).split("/"); return parts[0].startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0]; } - return path.basename(best); + return path.basename(source); } /** @@ -219,7 +221,29 @@ async function buildAndAnalyze( loader: { ".css": "empty" }, ...(external ? { external } : { packages: "external" }), }); - const meta = result.metafile; + const outputs = result.metafile.outputs; + const jsChunks = Object.keys(outputs).filter((p) => p.endsWith(".js")); + + // Initial payload = the entry chunk plus its static-import closure. esbuild + // sets `entryPoint` on code-split (dynamic-import) chunks too, so we can't use + // that flag — walk the static-import graph instead. Anything reachable only + // via a dynamic import (e.g. apache-arrow) is lazy. + const entryChunk = jsChunks.find( + (p) => outputs[p].entryPoint && absFile.endsWith(outputs[p].entryPoint), + ); + const initial = new Set(); + const stack = entryChunk ? [entryChunk] : []; + while (stack.length) { + const p = stack.pop() as string; + if (initial.has(p)) continue; + initial.add(p); + for (const imp of outputs[p].imports) { + if (imp.kind === "import-statement" && outputs[imp.path]) { + stack.push(imp.path); + } + } + } + const a: Analysis = { totalMinified: 0, totalGzip: 0, @@ -227,28 +251,29 @@ async function buildAndAnalyze( nodeModules: 0, initialGzip: 0, lazyGzip: 0, - lazyChunks: 0, - lazyChunkList: [], + chunks: [], }; - for (const [outPath, out] of Object.entries(meta.outputs)) { - if (!outPath.endsWith(".js")) continue; + for (const p of jsChunks) { + const out = outputs[p]; a.totalMinified += out.bytes; for (const [input, info] of Object.entries(out.inputs)) { if (input.includes("node_modules")) a.nodeModules += info.bytesInOutput; else a.own += info.bytesInOutput; } - const file = result.outputFiles.find((f) => f.path.endsWith(outPath)); + const file = result.outputFiles.find((f) => f.path.endsWith(p)); const gzip = file ? gzipSync(file.contents).byteLength : 0; a.totalGzip += gzip; - if (out.entryPoint) a.initialGzip += gzip; - else { - a.lazyGzip += gzip; - a.lazyChunks += 1; - a.lazyChunkList.push({ label: chunkLabel(out.inputs), gzip }); - } + const kind = initial.has(p) ? "initial" : "lazy"; + if (kind === "initial") a.initialGzip += gzip; + else a.lazyGzip += gzip; + a.chunks.push({ label: chunkLabel(out), gzip, kind }); } - a.lazyChunkList.sort( - (x, y) => y.gzip - x.gzip || x.label.localeCompare(y.label), + // Initial chunks first, then lazy; largest first within each group. + a.chunks.sort( + (x, y) => + (x.kind === y.kind ? 0 : x.kind === "initial" ? -1 : 1) || + y.gzip - x.gzip || + x.label.localeCompare(y.label), ); return a; } @@ -261,38 +286,32 @@ async function measureEntry( ): Promise> { if (!fs.existsSync(absFile)) return { minified: null, gzip: null }; try { - // Own-code cost (deps external) — the headline, stable import cost. + // Headline import cost = own code with deps external (what our code weighs, + // independent of dependency churn). Stable and attributable to our changes. const own = await buildAndAnalyze(absFile, platform); - const composition: Composition = { - own: own.own, - initialGzip: own.initialGzip, - lazyGzip: own.lazyGzip, - lazyChunks: own.lazyChunks, - lazyChunkList: own.lazyChunkList, - nodeModules: null, - bundledGzip: null, - }; - // Deps-bundled view (browser packages, deep modes only). Best-effort: if a - // dep can't be bundled, leave the node_modules columns empty. Browser - // packages lazy-load dependencies (e.g. apache-arrow), not their own - // modules, so the meaningful lazy chunks only appear once deps are bundled — - // take the lazy split and chunk list from this build too. + + // Composition view. Browser packages bundle deps (peerDeps external) so it + // reflects the real consumer bundle — lazy-loaded deps like apache-arrow + // land in lazy chunks. Node packages keep deps external (as shipped). + let view = own; + let nodeModules: number | null = null; if (deep && bundleExternal) { try { - const bundled = await buildAndAnalyze( - absFile, - platform, - bundleExternal, - ); - composition.nodeModules = bundled.nodeModules; - composition.bundledGzip = bundled.totalGzip; - composition.lazyGzip = bundled.lazyGzip; - composition.lazyChunks = bundled.lazyChunks; - composition.lazyChunkList = bundled.lazyChunkList; + view = await buildAndAnalyze(absFile, platform, bundleExternal); + nodeModules = view.nodeModules; } catch { - // leave deps-bundled fields at their own-code defaults + view = own; // a dep couldn't be bundled — fall back to the own-code view } } + + const composition: Composition = { + initialGzip: view.initialGzip, + lazyGzip: view.lazyGzip, + totalGzip: view.totalGzip, + own: view.own, + nodeModules, + chunks: view.chunks, + }; return { minified: own.totalMinified, gzip: own.totalGzip, composition }; } catch { return { minified: null, gzip: null }; @@ -487,50 +506,45 @@ function renderMarkdown( ); } lines.push(""); + const hasBaseline = Boolean(baseline); const bundleDeps = PACKAGES.find((p) => p.name === pkg.name)?.bundleDeps; + const scope = bundleDeps + ? "consumer bundle — deps bundled, peerDeps external" + : "own code — deps external (as shipped)"; lines.push( - "
Per-entry composition — own code (deps external) + lazy chunks; node_modules = weight if bundled", + `
Per-entry composition (${scope})`, "", - "| Entry | Import cost (gz) | Own code (min) | Lazy (gz) | node_modules (min) | + deps total (gz) |", + "| Entry | Initial (gz) | Lazy (gz) | Total (gz) | node_modules (min) | Own code (min) |", "| --- | --- | --- | --- | --- | --- |", ); for (const entry of pkg.entries) { - const prev = base?.entries.find((e) => e.id === entry.id) ?? null; const c = entry.composition; - const pc = prev?.composition ?? null; - const lazy = - c && c.lazyChunks > 0 - ? `${formatBytes(c.lazyGzip)} · ${c.lazyChunks} chunk${c.lazyChunks > 1 ? "s" : ""}` - : "none"; + const pc = + base?.entries.find((e) => e.id === entry.id)?.composition ?? null; const nodeModules = c?.nodeModules != null - ? cell(c.nodeModules, pc?.nodeModules ?? null, Boolean(baseline)) - : bundleDeps - ? "n/a" - : "external"; - const bundled = - c?.bundledGzip != null - ? formatBytes(c.bundledGzip) - : bundleDeps - ? "n/a" - : "—"; + ? cell(c.nodeModules, pc?.nodeModules ?? null, hasBaseline) + : "external"; lines.push( - `| \`${entry.id}\` | ${cell(entry.gzip, prev?.gzip ?? null, Boolean(baseline))} | ${cell(c?.own ?? null, pc?.own ?? null, Boolean(baseline))} | ${lazy} | ${nodeModules} | ${bundled} |`, + `| \`${entry.id}\` | ${cell(c?.initialGzip ?? null, pc?.initialGzip ?? null, hasBaseline)} | ${cell(c?.lazyGzip ?? null, pc?.lazyGzip ?? null, hasBaseline)} | ${cell(c?.totalGzip ?? null, pc?.totalGzip ?? null, hasBaseline)} | ${nodeModules} | ${cell(c?.own ?? null, pc?.own ?? null, hasBaseline)} |`, ); } - // List the individual lazy chunks (gzip) under the table. - const withChunks = pkg.entries.filter( - (e) => (e.composition?.lazyChunkList?.length ?? 0) > 0, + // Every emitted chunk, per entry. + const chunkRows = pkg.entries.flatMap((entry) => + (entry.composition?.chunks ?? []).map( + (chunk) => + `| \`${entry.id}\` | \`${chunk.label}\` | ${chunk.kind} | ${formatBytes(chunk.gzip)} |`, + ), ); - if (withChunks.length > 0) { - lines.push("", "**Lazy chunks (gzip):**"); - for (const entry of withChunks) { - for (const chunk of entry.composition?.lazyChunkList ?? []) { - lines.push( - `- \`${entry.id}\` → \`${chunk.label}\` ${formatBytes(chunk.gzip)}`, - ); - } - } + if (chunkRows.length > 0) { + lines.push( + "", + "**Chunks:**", + "", + "| Entry | Chunk | Load | Size (gz) |", + "| --- | --- | --- | --- |", + ...chunkRows, + ); } lines.push("", "
", ""); } From d638b75da9e3285d6650562c9415e09a246eabbf Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 1 Jul 2026 19:19:31 +0200 Subject: [PATCH 7/7] ci: split dist by file type, add missing entries, honest tarball label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit fixes for report veracity: - Break dist/ into JS (runtime) / type declarations / source maps / CSS, each raw+gzip. The old lump hid that ~55-58% of dist is sourcemaps and ~15-20% is .d.ts — only ~30% is runtime JS. (Also surfaces that ~1.3 MB of maps ship despite sourcemap:false, since files+dist include them wholesale.) - Add missing published entries: appkit ./type-generator; appkit-ui styles.css now shown via the CSS dist bucket. - Relabel the npm tarball line: it is npm pack of the package dir (dist+bin), which excludes release-only docs/NOTICE/llms/shared-CLI assembled at publish. Signed-off-by: MarioCadenas --- bundle-size-baseline.json | 75 ++++++++++++++++++++++-- tools/bundle-size.ts | 119 ++++++++++++++++++++++++++------------ 2 files changed, 150 insertions(+), 44 deletions(-) diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index 1ce50fd59..2c96f82f8 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,12 +3,34 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 678473, + "packed": 678467, "unpacked": 2398820 }, "dist": { - "raw": 2394404, - "gzip": 802685, + "total": { + "raw": 2394404, + "gzip": 802675 + }, + "js": { + "raw": 705697, + "gzip": 246586 + }, + "types": { + "raw": 274553, + "gzip": 93028 + }, + "maps": { + "raw": 1403359, + "gzip": 459243 + }, + "css": { + "raw": 0, + "gzip": 0 + }, + "other": { + "raw": 10795, + "gzip": 3818 + }, "fileCount": 522 }, "entries": [ @@ -84,18 +106,59 @@ } ] } + }, + { + "id": "./type-generator", + "minified": 55019, + "gzip": 19056, + "composition": { + "initialGzip": 19056, + "lazyGzip": 0, + "totalGzip": 19056, + "own": 54837, + "nodeModules": null, + "chunks": [ + { + "label": "index.js", + "gzip": 19056, + "kind": "initial" + } + ] + } } ] }, { "name": "@databricks/appkit-ui", "tarball": { - "packed": 303759, + "packed": 303755, "unpacked": 1288650 }, "dist": { - "raw": 1284681, - "gzip": 424471, + "total": { + "raw": 1284681, + "gzip": 424469 + }, + "js": { + "raw": 371553, + "gzip": 122929 + }, + "types": { + "raw": 208062, + "gzip": 74449 + }, + "maps": { + "raw": 688206, + "gzip": 223745 + }, + "css": { + "raw": 16860, + "gzip": 3346 + }, + "other": { + "raw": 0, + "gzip": 0 + }, "fileCount": 475 }, "entries": [ diff --git a/tools/bundle-size.ts b/tools/bundle-size.ts index da06d4e16..dad069c95 100644 --- a/tools/bundle-size.ts +++ b/tools/bundle-size.ts @@ -4,10 +4,13 @@ * Measures and tracks the bundle size of the published packages (`appkit`, * `appkit-ui`). Three metrics per package: * - * 1. Tarball packed / unpacked size — what `npm publish` ships, via - * `npm pack --dry-run --json`. Dependencies stay external (matches how - * the packages build), so this is the size of our own shipped files. - * 2. `dist/` raw + gzip total — every built file summed. + * 1. npm tarball (packed) — the gzipped download, via `npm pack --dry-run + * --json` on the package dir (dist + bin). Excludes release-only files + * (docs/NOTICE/llms/shared CLI) that dist-appkit.ts assembles at publish + * time, so it slightly under-reports the true published tarball. + * 2. `dist/` size split by file type — JS (runtime), type declarations, + * source maps, CSS — each raw + gzip. Only `js` is runtime code; maps and + * declarations often dominate the raw total. * 3. Per-entry import cost — each entry point (`.`, `./react`, ...) bundled * with esbuild, minified and gzipped, with dependencies kept external * (`packages: "external"`). Reflects the reachable own-code cost per @@ -74,6 +77,7 @@ const PACKAGES: PackageConfig[] = [ entries: [ { id: ".", file: "dist/index.js" }, { id: "./beta", file: "dist/beta.js" }, + { id: "./type-generator", file: "dist/type-generator/index.js" }, ], }, { @@ -118,10 +122,27 @@ interface EntryMeasurement { composition?: Composition; } +interface SizePair { + raw: number; + gzip: number; +} + +// dist/ split by file type so sourcemaps and type declarations don't hide +// inside the headline number — only `js` is runtime code. +interface DistBreakdown { + total: SizePair; + js: SizePair; + types: SizePair; // .d.ts / .d.mts / .d.cts + maps: SizePair; // .js.map, .d.ts.map, ... + css: SizePair; + other: SizePair; + fileCount: number; +} + interface PackageMeasurement { name: string; tarball: { packed: number; unpacked: number } | null; - dist: { raw: number; gzip: number; fileCount: number }; + dist: DistBreakdown; entries: EntryMeasurement[]; } @@ -136,17 +157,39 @@ function walkFiles(dir: string): string[] { return out; } -function measureDist(dir: string): PackageMeasurement["dist"] { - let raw = 0; - let gzip = 0; - let fileCount = 0; +function measureDist(dir: string): DistBreakdown { + const zero = (): SizePair => ({ raw: 0, gzip: 0 }); + const d: DistBreakdown = { + total: zero(), + js: zero(), + types: zero(), + maps: zero(), + css: zero(), + other: zero(), + fileCount: 0, + }; + const add = (b: SizePair, raw: number, gzip: number) => { + b.raw += raw; + b.gzip += gzip; + }; for (const file of walkFiles(dir)) { const contents = fs.readFileSync(file); - raw += contents.byteLength; - gzip += gzipSync(contents).byteLength; - fileCount += 1; + const raw = contents.byteLength; + const gzip = gzipSync(contents).byteLength; + add(d.total, raw, gzip); + d.fileCount += 1; + const bucket = file.endsWith(".map") + ? d.maps + : /\.d\.[cm]?ts$/.test(file) + ? d.types + : file.endsWith(".css") + ? d.css + : /\.[cm]?js$/.test(file) + ? d.js + : d.other; + add(bucket, raw, gzip); } - return { raw, gzip, fileCount }; + return d; } function measureTarball(dir: string): PackageMeasurement["tarball"] { @@ -419,13 +462,10 @@ function printTable( pkg.tarball?.packed ?? null, base?.tarball?.packed ?? null, ], - [ - "tarball unpacked", - pkg.tarball?.unpacked ?? null, - base?.tarball?.unpacked ?? null, - ], - ["dist raw", pkg.dist.raw, base?.dist.raw ?? null], - ["dist gzip", pkg.dist.gzip, base?.dist.gzip ?? null], + ["dist total", pkg.dist.total.raw, base?.dist.total.raw ?? null], + [" js", pkg.dist.js.raw, base?.dist.js.raw ?? null], + [" type defs", pkg.dist.types.raw, base?.dist.types.raw ?? null], + [" sourcemaps", pkg.dist.maps.raw, base?.dist.maps.raw ?? null], ]; for (const [label, cur, prev] of rows) { const delta = baseline ? ` ${formatDelta(cur, prev)}` : ""; @@ -485,28 +525,31 @@ function renderMarkdown( exceeded = exceeded || pkgExceeded; lines.push(`### \`${pkg.name}\`${pkgExceeded ? " ⚠️ over budget" : ""}`, ""); - lines.push("| Metric | main | this PR | Δ |", "| --- | --- | --- | --- |"); - const rows: [string, number | null, number | null][] = [ - [ - "Tarball (packed)", - pkg.tarball?.packed ?? null, - base?.tarball?.packed ?? null, - ], - [ - "Tarball (unpacked)", - pkg.tarball?.unpacked ?? null, - base?.tarball?.unpacked ?? null, - ], - ["dist (raw)", pkg.dist.raw, base?.dist.raw ?? null], - ["dist (gzip)", pkg.dist.gzip, base?.dist.gzip ?? null], + const hasBaseline = Boolean(baseline); + lines.push( + `**npm tarball (packed): ${cell(pkg.tarball?.packed ?? null, base?.tarball?.packed ?? null, hasBaseline)}** — gzipped download (dist + bin; excludes release-only docs/NOTICE).`, + "", + "| dist | raw | gzip |", + "| --- | --- | --- |", + ); + const bd = base?.dist ?? null; + const distRows: [string, SizePair, SizePair | null][] = [ + ["JS (runtime)", pkg.dist.js, bd?.js ?? null], + ["Type declarations", pkg.dist.types, bd?.types ?? null], + ["Source maps", pkg.dist.maps, bd?.maps ?? null], + ["CSS", pkg.dist.css, bd?.css ?? null], + ["Other", pkg.dist.other, bd?.other ?? null], ]; - for (const [label, cur, prev] of rows) { + for (const [label, cur, prev] of distRows) { + if (cur.raw === 0 && (prev?.raw ?? 0) === 0) continue; // skip empty buckets lines.push( - `| ${label} | ${formatBytes(prev)} | ${formatBytes(cur)} | ${baseline ? formatDelta(cur, prev) : "—"} |`, + `| ${label} | ${cell(cur.raw, prev?.raw ?? null, hasBaseline)} | ${cell(cur.gzip, prev?.gzip ?? null, hasBaseline)} |`, ); } - lines.push(""); - const hasBaseline = Boolean(baseline); + lines.push( + `| **Total** | ${cell(pkg.dist.total.raw, bd?.total.raw ?? null, hasBaseline)} | ${cell(pkg.dist.total.gzip, bd?.total.gzip ?? null, hasBaseline)} |`, + "", + ); const bundleDeps = PACKAGES.find((p) => p.name === pkg.name)?.bundleDeps; const scope = bundleDeps ? "consumer bundle — deps bundled, peerDeps external"