Skip to content

feat(build-cli): bundle size collection and comparison tooling#27151

Merged
TommyBrosman merged 75 commits into
microsoft:mainfrom
TommyBrosman:tbrosman/compare-bundles
Jun 19, 2026
Merged

feat(build-cli): bundle size collection and comparison tooling#27151
TommyBrosman merged 75 commits into
microsoft:mainfrom
TommyBrosman:tbrosman/compare-bundles

Conversation

@TommyBrosman

@TommyBrosman TommyBrosman commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Adds a local dev workflow for collecting webpack bundle stats from a base
git ref and comparing them against the current ref - per-asset parsed/gzip
deltas, per-entrypoint totals, and per-package size breakdown. Checks out an
isolated shallow clone at the base revision so the outer repo is never
touched. Intended for inner-loop bundle investigations, not CI.

Description

Core implementation (src/library/bundleSize):

  • collectBundle: builds + runs webpack for one revision (local working tree or
    an inner enlistment at a given ref) and saves its analyzer.json.
  • compareBundles: diffs two analyzer.json reports into per-asset, per-entrypoint,
    and per-package tables (text + JSON), tolerating differing asset/package sets.
  • collectAndCompareBundles: orchestrates both sides then compares; base ref is
    the merge-base of HEAD by default, or used as-is with exactBase. Caches the
    base report by SHA to skip rebuilds.
  • Supporting: compareJsonReports, types, analyzer.json readers, index.

flub commands (src/commands/generate, src/commands/check + docs):

  • generate bundleAnalysisRepo, check bundleAnalysisReposComparison, and
    generate bundleAnalysisReposWithComparison wrap the above. Regenerated
    docs/generate.md and docs/check.md, with the companion deep-dive in
    docs/bundleAnalysisRepoDetails.md.

Client code (non-shipping):

  • Stub script in bundle-size-tests that invokes generate bundleAnalysisReposWithComparison.
  • bundle-size-tests now depends on the catalog-pinned buildTools package.
  • pnpm-workspace exclusion so the inner enlistment isn't scanned by fluid-build.

Todo

  • Use oclif
  • Determine where the scripts should live: bundle-analysis-tools (client) or build-tools.
  • README entry.
  • Need a better home for the README (currently I'm using the example project, which isn't the right place)
    • Moved to bundleDetails.md alongside the bundle.md auto-generated command documentation.
  • Thoughts on the bundle topic for the oclif CLI? Should we keep it, standardize on check, or something else?
    • Standardize on existing verbs generate/check.
  • Make a pass over the comments, prefer @remarks for verbose "why" comments.
  • Determine whether collectAndCompare can be eliminated from the initial version of the scripts.
    • Keeping it for now, though most of the original functionality has been pushed into collect.
  • Are there methods that can be reused from the existing bundle size tools?
    • A more in-depth pass will be saved for future PRs.

Note: includes changes to the pnpm-lock and pnpm-workspace that need to be reverted before this change is reviewed or checked in.
Comment thread pnpm-workspace.yaml Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Hey! You look nice today! Want me to review this PR?

Based on the diff (1476 lines, 6 files), I've queued these reviewers:

  • Correctness — logic errors, race conditions, lifecycle issues
  • Security — vulnerabilities, secret exposure, injection
  • API Compatibility — breaking changes, release tags, type design
  • Performance — algorithmic regressions, memory leaks
  • Testing — coverage gaps, hollow tests

Toggle checkboxes to adjust, then reply yes to start — or ask me anything!

Comment thread examples/utils/bundle-size-tests/scripts/collectAndCompareBundles.ts Outdated
Comment thread examples/utils/bundle-size-tests/scripts/collectAndCompareBundles.ts Outdated
@TommyBrosman TommyBrosman marked this pull request as ready for review April 25, 2026 00:06
Copilot AI review requested due to automatic review settings April 25, 2026 00:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a local developer workflow for collecting webpack bundle stats from a “base” git ref and comparing them against the current ref, including per-asset parsed + gzip deltas. This lives under examples/utils/bundle-size-tests and is intended to speed up iterative bundle-structure investigations (not CI).

Changes:

  • Add collectBundles.ts, compareBundles.ts, and collectAndCompareBundles.ts scripts plus npm script entry points.
  • Persist collected stats/build outputs under an OS temp directory to survive git checkout and repo cleans.
  • Extend lint/tsconfig setup so the new scripts/ TypeScript files are type-checked and linted; add @msgpack/msgpack for decoding stats.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pnpm-lock.yaml Adds @msgpack/msgpack and updates lock resolutions (includes an unrelated peer-resolution change flagged in comments).
examples/utils/bundle-size-tests/tsconfig.scripts.json New TS config to include scripts/**/*.ts in type-aware tooling.
examples/utils/bundle-size-tests/scripts/compareBundles.ts New comparison/report generator (text + JSON), including gzip computation for changed assets.
examples/utils/bundle-size-tests/scripts/collectBundles.ts New collector that stashes changes, checks out refs, builds, saves stats, and restores git state.
examples/utils/bundle-size-tests/scripts/collectAndCompareBundles.ts New wrapper to run collection then comparison via node --import jiti/register.
examples/utils/bundle-size-tests/package.json Adds collect:* / compare:* scripts, lints scripts/, and adds @msgpack/msgpack.
examples/utils/bundle-size-tests/eslint.config.mts Ensures typed linting covers script TS files via explicit tsconfig list + script-specific rule relaxations.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)

examples/utils/bundle-size-tests/scripts/compareBundles.ts:490

  • This script calls main(process.argv) instead of process.argv.slice(2), unlike the other scripts in this folder. While option parsing still mostly works, it’s easy to introduce subtle parsing bugs/inconsistencies (e.g., if later you add positional args). Consider switching to process.argv.slice(2) for consistency with the other CLIs here.
main(process.argv);

Comment thread examples/utils/bundle-size-tests/scripts/collectAndCompareBundles.ts Outdated
Comment thread pnpm-lock.yaml
Comment thread examples/utils/bundle-size-tests/scripts/collectBundles.ts Outdated
Comment thread examples/utils/bundle-size-tests/scripts/collectBundles.ts Outdated
… the report

Merge bucketPackageSizes and the inline rowsForAsset memoization closure
into a single comparePackages(baseNodes, currentNodes) that returns both
the composition buckets and the full per-package breakdown. The
fluidFrameworkAll breakdown now reuses the rows already memoized during
the bucket pass instead of walking that asset's module tree a second time.

Also:
- Rename computeComparison -> computeBundleComparison for clarity.
- Filter the gzip asset table on its own gzip diff rather than on the
  parsed-size change set, so gzip-only changes are no longer hidden and
  parsed-only changes no longer show up as noise rows.
- Document the report's tables, measurement units (parsed vs gzip), and
  attribution rules in the bundle-size-tests README.

Behavior-preserving: bundle compare output is byte-identical.
packageFromModulePath and canonicalModuleKey both stripped the
concatenation wrapper and then ran the same "last node_modules/, else
first packages/" anchoring logic. Pull that shared step into a single
moduleAnchor helper that returns the matched marker and index, so each
function reduces to consuming the anchor it needs.

Also rename the "(app/entry)" concept from "harness" to "synthetic
entrypoint" across the comments and README (the data label is unchanged),
and rename moduleAnchor's parameter to modulePath for clarity.

No behavior change: bundle compare output is byte-identical.
Document, in the CollectBundleOptions.mode JSDoc, the --mode flag help, and
the local-mode branch + captureLocalPatch doc, that local collection builds
the outer enlistment exactly as it sits on disk: its git state (working tree,
branch, revision) is never modified, and the captured staged-changes patch is
only a reproducibility record that is never applied.
Relocate the conceptual `flub bundle` documentation (how the commands fit
together and how to read the comparison report) out of the bundle-size-tests
package README and into build-cli's docs/bundleDetails.md, following the same
*Details.md convention as typetestDetails.md. Link to it from each bundle
command's description so it is discoverable via --help and the generated
bundle.md, and trim the package README to its package-specific wrapper plus a
pointer to the new doc.
…vision mode

Revision-mode bundle collection previously created the inner repo by shallow-cloning the outer repo's origin remote and fetching the requested commit by SHA, which required the commit to exist on origin and depended on network access plus uploadpack.allowReachableSHA1InWant.

Clone the inner repo directly from the outer enlistment on disk instead. No remote or network access is involved, and every object the outer repo already has (including merge-base SHAs that aren't branch tips) is available without a fetch; the requested revision is checked out detached. The commit now only needs to exist locally. Removes the now-unused getOuterOriginUrl helper and updates bundleDetails.md.
TommyBrosman added a commit that referenced this pull request Jun 16, 2026
#27531)

## Description

Enable `importHelpers` (with a `tslib` dependency) in
`@fluidframework/tree`. With this on, the TypeScript compiler emits
`import { ... } from "tslib"` instead of inlining the runtime helpers
(e.g. `__assign`, `__awaiter`, `__spreadArray`) into every module that
uses them. Webpack can then dedupe those helpers down to a single shared
copy, shrinking the bundle.

Measured impact (parsed bytes), comparing against the parent revision:

| Entrypoint | Base | Current | Diff | % |
|---|--:|--:|--:|--:|
| `sharedTree.js` | 401,086 | 392,105 | **−8,981** | **-2.2%** |
| `fluidFrameworkAll.js` | 1,326,045 | 1,317,064 | **-8,981** |
**-0.7%** |

Gzipped: `sharedTree.js` -1,948 B, `fluidFrameworkAll.js` -1,949 B.

The mechanism is visible in the per-package breakdown: inlined helpers
drop out of `@fluidframework/fluid-framework` (-12,499 B) while a single
shared `tslib` module is added (+3,902 B), for a net ~-8,981 B per
affected entrypoint. All other entrypoints are unchanged.

Changes:
- Add `"importHelpers": true` to `packages/dds/tree/tsconfig.json`.
- Add `tslib` as a dependency in `packages/dds/tree/package.json` (and
corresponding lockfile update).

## Reviewer Guidance

- `tslib` is added as a runtime dependency. This is not a widely-used
dependency in our repo (client-logger/fluid-telemetry uses it, so does
the devtools browser extension). Are there any potential issues if tree
depends on it?
- The bundle numbers above were produced with `flub bundle
collect-and-compare` against the commit's parent revision. See #27151
for the version of the tools used.

@ChumpChief ChumpChief left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We talked about a couple of these offline already but wanted to get you this set of comments.

I think eventually there's a lot of opportunity to share/reuse with the pipeline bundle comparison tools (i.e. merge for the union of their functionalities), but I think starting with a checkin of a standalone tool like you're doing here is the right move to retain agility as we iterate even if there's some duplicated functionality short-term.

Comment thread examples/utils/bundle-size-tests/src/fluidFrameworkAll.ts Outdated
Comment thread build-tools/packages/build-cli/package.json Outdated
Comment thread build-tools/packages/build-cli/src/library/bundleSize/collectBundle.ts Outdated
Comment thread build-tools/packages/build-cli/src/commands/bundle/collectAndCompare.ts Outdated
Comment thread build-tools/packages/build-cli/src/library/bundleSize/collectAndCompareBundles.ts Outdated
Comment thread examples/utils/bundle-size-tests/webpack.config.cjs Outdated
Comment thread build-tools/packages/build-cli/src/library/bundleSize/compareBundles.ts Outdated
Relocate the longer explanatory inline comments in the bundle size scripts (collectBundle, collectAndCompareBundles, compareBundles) into @remarks blocks on the owning functions' doc comments, decluttering the function bodies while preserving the rationale.

Short structural one-liners (e.g. section labels in renderAsText, the leaf-node marker, and the compareJsonReports shared-primitive note) are left inline. Comment-only change: no behavior, signatures, or public API affected.
In bundle revision mode the inner repo is a clone of the outer repo, so a branch from the outer repo exists in the clone only as a remote-tracking ref (origin/<branch>). A bare branch name therefore failed to resolve under 'git checkout --detach', and relative committishes such as HEAD~2 resolved against the inner clone's HEAD (the wrong commit).

ensureInnerRepoAtRevision now selects the checkout target explicitly: an exact branch is checked out by its qualified origin/<branch> name (readable, never turned into a SHA), while anything else - a commit SHA, tag, or committish - is resolved to a commit SHA in the outer repo (the source of truth for HEAD and relative refs) and that SHA, which the full clone already contains, is checked out. Branch detection uses a non-throwing 'git for-each-ref' probe.

Also broadens the --revision flag help to mention committishes. Validated end-to-end by collecting a branch (tbrosman/eslint-config-13), HEAD~2, and a bare SHA.
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Bundle size comparison

Base commit: 0c6c7e4f51580e2ccf19c4d3ee302acd2ffdacbe
Head commit: bf23dc7b60ed16450d33fccf68d2afee34bcd193

Notable changes

No bundles changed by ≥ 500 bytes parsed.

Per-bundle deltas

@fluid-example/bundle-size-tests

  • azureClient.js: parsed 618613 → 618669 (+56), gzip 164734 → 164780 (+46)
  • odspClient.js: parsed 591845 → 591901 (+56), gzip 158885 → 158926 (+41)
  • aqueduct.js: parsed 525463 → 525498 (+35), gzip 140683 → 140714 (+31)
  • fluidFramework.js: parsed 392149 → 392170 (+21), gzip 111130 → 111148 (+18)
  • sharedTree.js: parsed 381536 → 381550 (+14), gzip 108525 → 108536 (+11)
  • containerRuntime.js: parsed 303813 → 303827 (+14), gzip 83188 → 83196 (+8)
  • sharedString.js: parsed 175984 → 175991 (+7), gzip 49445 → 49452 (+7)
  • experimentalSharedTree.js: parsed 160798 → 160798 (0), gzip 45804 → 45804 (0)
  • matrix.js: parsed 159845 → 159852 (+7), gzip 45411 → 45418 (+7)
  • loader.js: parsed 145307 → 145321 (+14), gzip 39063 → 39078 (+15)
  • odspDriver.js: parsed 104423 → 104444 (+21), gzip 32644 → 32651 (+7)
  • directory.js: parsed 66616 → 66623 (+7), gzip 18532 → 18540 (+8)
  • 748.js: parsed 58793 → 58793 (0), gzip 17826 → 17826 (0)
  • map.js: parsed 46709 → 46716 (+7), gzip 14310 → 14317 (+7)
  • odspPrefetchSnapshot.js: parsed 45642 → 45656 (+14), gzip 15268 → 15276 (+8)
  • 594.js: parsed 44493 → 44493 (0), gzip 13744 → 13744 (0)
  • summarizerDelayLoadedModule.js: parsed 30753 → 30753 (0), gzip 7767 → 7767 (0)
  • socketModule.js: parsed 26486 → 26493 (+7), gzip 7879 → 7887 (+8)
  • createNewModule.js: parsed 12480 → 12480 (0), gzip 4786 → 4786 (0)
  • summaryModule.js: parsed 3797 → 3797 (0), gzip 1860 → 1860 (0)
  • connectionState.js: parsed 724 → 724 (0), gzip 429 → 429 (0)
  • sharedTreeAttributes.js: parsed 666 → 673 (+7), gzip 431 → 441 (+10)
  • debugAssert.js: parsed 429 → 429 (0), gzip 299 → 299 (0)
  • FluidFramework-HashFallback.js: parsed 422 → 422 (0), gzip 316 → 316 (0)

… and simplify collect flags

Move revision resolution (merge-base/exact), base-report caching via a
revision.txt sidecar, and default label generation (timestamped
current_<epoch>) out of the collect-and-compare orchestrator and into
collectBundle, which now returns the label it saved under.
collectAndCompareBundles is reduced to sequencing collect/collect/compare
and deleting the scratch inner repo after the comparison.

Redesign the collect command around committish-driven flags, replacing the
--mode/--revision/--exact-base trio:
  - --revision <committish>: build that committish resolved as-is
  - --merge-base <committish>: build the merge-base of HEAD and the committish
The two are mutually exclusive; omitting both collects the local working tree.
CollectBundleOptions.exactBase is replaced with resolution: 'exact' | 'merge-base'.

Also remove the unused --skip-compare flag from collect-and-compare, simplify
ensureInnerRepoAtRevision to check out an already-resolved SHA, and regenerate
docs/bundle.md.
…lectAndCompare

Rename the command file to camelCase to match the repo's command-naming
convention (e.g. `flub generate compatLayerGeneration`). This changes the
invoked command from `flub bundle collect-and-compare` to
`flub bundle collectAndCompare`, consistent with its siblings collect/compare.

Update the bundle-size-tests compare:bundles script, the pnpm-workspace.yaml
comment, and bundleDetails.md to the new name, and regenerate docs/bundle.md.
…undle

Replace CollectBundleOptions.resolution with a dedicated mergeBaseOf committish:
revision is always built as-is, mergeBaseOf builds the merge-base with HEAD, so
specifying a revision no longer silently resolves to the fork point. Update the
collect command (--merge-base) and the collect-and-compare orchestrator to match.

Restructure collectBundle into clear phases (validate, resolve+cache, prepare env,
build, save) via resolveBuildRevision/prepareRevisionBuild helpers, and tighten the
helper doc comments.
Group the helpers into labeled sections (utilities, git resolution, build
steps, environment prep, output, orchestrator) so related functions sit
together. Merge buildWorkspace/buildBundles into a single buildPackage, make
resolveMergeBase throw on failure (symmetric with resolveSha), simplify
resolveBuildRevision to (committish, useMergeBase) with no cast, and hoist the
analyzer.json / revision.txt filenames into shared constants.

No behavior change.
Remove the "Named entrypoint total asset sizes" table from the bundle comparison
(compareBundles) and its bundleDetails.md docs, plus the now-unused entrypoint
helpers — those sizes are already captured in the per-asset table.

In collectAndCompare, delete the scratch inner repo only after the report is
written and the completion banner is shown, so the user sees results before the
slow cleanup. Also rename the analyzer/revision filename constants to camelCase
to match the bundleSize module and trim an overly verbose doc comment.
Move the default-base resolution into collectBundle: when revision mode is requested without an explicit revision/mergeBaseOf, it now defaults to the 'main' branch of the freshest remote pointing at microsoft/FluidFramework (via pickFreshestRemote) at its merge-base with HEAD, rather than relying on the local 'main' which may be stale or absent in worktree setups.

collectAndCompare no longer resolves the base itself; its --base-revision flag has no default and is passed through as undefined when omitted, so an explicit value (including 'main') is always honored as given. Updates docs and regenerates docs/bundle.md.
Retopic and rename the bundle-analysis commands so they live under the standardized generate/check topics:

- 'bundle collect' -> 'generate bundleAnalysisRepo'
- 'bundle compare' -> 'check bundleAnalysisReposComparison'
- 'bundle collectAndCompare' -> 'generate bundleAnalysisReposWithComparison'

Removes the now-empty 'bundle' oclif topic, renames the companion doc to bundleAnalysisRepoDetails.md (a durable, easy-to-find name), updates all command cross-references (compare:bundles script, bundle-size-tests README, pnpm-workspace comment), and regenerates the oclif manifest and docs. The underlying library functions are unchanged.

@ChumpChief ChumpChief left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the tool seems fine to go in - I'm sure we'll iterate on it some over time but it seems helpful as-is.

I do think we should probably drop the fluidFrameworkAll bundle for now though, just to minimize disruption to our existing bundle telemetry. I can try to prioritize fixing AB#75724 to unblock it, but I think that should happen first so we can have as few "telemetry reset" events as possible.

Comment thread examples/utils/bundle-size-tests/src/fluidFrameworkAll.ts Outdated
Comment thread examples/utils/bundle-size-tests/eslint.config.mts Outdated
Comment thread build-tools/packages/build-cli/src/commands/generate/bundleAnalysisRepo.ts Outdated
- eslint.config.mts: drop the leftover projectService override block, orphaned after the bundle scripts (and tsconfig.scripts.json) moved out of the package.

- pnpm-lock.yaml: revert incidental semver 7.7.3 -> 7.7.4 drift under @arethetypeswrong/core (a transitive dep nothing in this PR touches).

- package.json: remove the orphaned 'compile' fluidBuild task whose build:scripts dependency was already deleted.

- bundleSize/index.ts: stop re-exporting compareJsonReports, which is only used via direct import and consumed by no command through the index.

- README.md: correct the Outputs section to the actual compareBundlesOutput/ paths (analysis/<label>/, root reports, base-repo/).
Remove the fluidFrameworkAll aggregate entrypoint and everything that fed off it: delete examples/utils/bundle-size-tests/src/fluidFrameworkAll.ts, its webpack entry, and the now-unneeded extensionAlias resolver config.

In compareBundles.ts, re-scope the composition buckets and the per-package breakdown to the sharedTree entrypoint, drop the two Fluid Framework buckets, and update bundleAnalysisRepoDetails.md to match.

Clarify the bucketDefinitions docstring with a plain-language description of the (app/entry) exclusion and fix the malformed '@ remarks' tag.

Restore the jiti devDependency (and its lockfile entry) to bundle-size-tests. jiti is required for eslint 9 to load the TypeScript eslint.config.mts and was over-removed alongside the script-only deps; this realigns the package with the other 173 client packages that declare it.
Remove the --analysis-dir, --output-dir, --force-clean-build, and --label flags from the bundle-analysis commands (generate bundleAnalysisRepo, generate bundleAnalysisReposWithComparison, check bundleAnalysisReposComparison). Each command now derives its paths from --package-dir and the fixed compareBundlesOutput convention (per-label analyzer stats under compareBundlesOutput/analysis/<label>/, reports at compareBundlesOutput/), always builds without the workspace clean, and lets labels be auto-generated.

Keep --base-label/--current-label on the compare command, since pairing specific labels is needed for other workflows.

Define the 'label' concept in bundleAnalysisRepoDetails.md (the subdirectory each collected bundle's analyzer.json lands in) and make the collect command's --revision/--merge-base help self-explanatory now that --label is gone.

Fix a stale --force-clean-build example and regenerate the command docs.
@github-actions

Copy link
Copy Markdown
Contributor

🔗 No broken links found! ✅

Your attention to detail is admirable.

linkcheck output

1: starting server using command "npm run serve -- --no-open"
and when url "[ 'http://127.0.0.1:3000' ]" is responding with HTTP status code 200
running tests using command "npm run check-links"


> fluid-framework-website@0.0.0 serve
> docusaurus serve --no-open

[SUCCESS] Serving "build" directory at: http://localhost:3000/

> fluid-framework-website@0.0.0 check-links
> linkcheck http://localhost:3000 --skip-file skipped-urls.txt

Crawling...

Stats:
  290924 links
    1934 destination URLs
    2184 URLs ignored
       0 warnings
       0 errors


@TommyBrosman TommyBrosman merged commit c8ee7af into microsoft:main Jun 19, 2026
42 of 43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants