Skip to content

feat(verify-bundle-isolation): implement verify bundle isolation CLI and hook it to headless package - #36511

Open
Hotell wants to merge 13 commits into
microsoft:masterfrom
Hotell:feat/headless-bundle-isolation
Open

feat(verify-bundle-isolation): implement verify bundle isolation CLI and hook it to headless package#36511
Hotell wants to merge 13 commits into
microsoft:masterfrom
Hotell:feat/headless-bundle-isolation

Conversation

@Hotell

@Hotell Hotell commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Previous Behavior

Nothing stopped headless entry points from retaining Tabster, Griffel or React Icons after tree shaking. Bundle-size fixtures measured output size, but nothing inspected which packages actually survived into the bundle.

New Behavior

Adds @fluentui/verify-bundle-isolation, which bundles the existing monosize fixtures with webpack and fails when a forbidden package survives tree shaking. Wired into PR validation via nx affected -t verify-bundle-isolation.

Three verdicts, so the result is never overstated:

  • PASS — nothing survived. The only verdict that claims a bundle is free of the forbidden list.
  • PASS WITH DEBT — everything that survived is allowlisted. Exits 0, lists the debt.
  • FAIL — a regression, a stale/orphaned allowlist entry, or a fixture that failed to bundle. --strict also fails on allowlisted leaks.

Current output for this package:

  ALLOWED     AllComponents.fixture.js - 3 forbidden packages, 26 modules
    @griffel/core          11 modules  1 export
      via lib/tag-picker.js
    @fluentui/react-icons   9 modules  4 exports
      via lib/components/TagPicker/TagPickerControl/useTagPickerControl.js

PASS WITH DEBT - 1 fixture, 0 regressions, 3 allowed violations
  kept out:  tabster
  allowlist: @fluentui/react-icons, @griffel/core, @griffel/react

via points at the module in this package responsible: nothing imports react-portal directly, but ./tag-picker re-exports a render function that mounts a portal. Both leaks are already tracked — #36503 and #36504.

allowedViolations is shrink-only: adding an entry is a regression, and removing a leak fails the check until its entry is deleted.

It already found something

Following the via chain led to #36512. react-portal pulls in all 17 Griffel modules to apply five static CSS declarations — makeStyles for the mount node plus mergeClasses to compose its className — which is why every headless entry point re-exporting a portal-mounting render function inherits Griffel.

Stacked on #36503 and #36504, the portal-free base render removes 3.09 kB gzipped from /tag-picker (−15.0%) and 3.17 kB from the library overall. Worth noting the check was what made this findable: the leak is three packages deep and invisible from this package's imports.

Why attribution is non-trivial

webpack records import edges before tree shaking, so edges alone badly over-report — 20+ packages import the react-icons barrel. A package is reported only when its modules are in an emitted chunk, an export is in usedExports, and the importing module also survived. Dropping any one condition produces false positives such as blaming react-avatar for PersonRegular, which never ships.

Counts are modules and exports, not bytes: the analysis build runs unminified for attribution accuracy, so its sizes would misrepresent shipped cost. monosize remains the source of truth for size.

Validation

49 tests. bundle-isolation-plugin.spec.ts bundles a purpose-built module graph and locks in the attribution rules above — including that an eliminated importer is not blamed. report.spec.ts asserts the check never claims a bundle is "free of" a package that is merely allowlisted.

Also verified by hand: exits 1 when an allowlist entry is removed, PASS reached by narrowing forbiddenPackages to an absent package, and detection cross-checked against the emitted monosize bundle.

Notes for reviewers

  • --analyze additionally writes a treemap per fixture; summary.json is always written for CI.
  • Webpack is pinned repo-wide via resolutions. The verdict is only meaningful if it comes from the same bundler as the bundle-size numbers, and 5.109 changed resolution so these packages resolve to sources.
  • The target uses inputs: ["default", "^default"] so the cache is invalidated by changes to the tool and to the packages whose built output it bundles.

Related Issue(s)

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

📊 Bundle size report

✅ No changes found

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Pull request demo site: URL

Comment thread .github/workflows/pr.yml
Hotell added 3 commits August 4, 2026 23:35
…with webpack

Replaces the esbuild check with a webpack plugin so the verdict comes from the same bundler that produces the bundle-size numbers.

Failures now name the exports that survived tree shaking and the modules importing them, instead of only listing retained module paths. Attribution intersects usedExports with active import connections and requires the importing module to survive into a chunk, so packages whose icon imports were eliminated are no longer blamed.

Adds --analyze to emit a webpack-bundle-analyzer treemap per fixture.
Describes the check in package-agnostic terms, drops the Nx target reference since the CLI runs standalone, and collapses the flag examples into a single table.
…he package

Naming the module that imports a forbidden package is not actionable when that module belongs to a dependency: Griffel is reported against react-portal, which this package never imports directly.

Each importer is now walked back over retained modules to the first module owned by the package under test, reported as 'via'. That surfaces lib/tag-picker.js as the origin, since it re-exports a render function that mounts a portal.

The origin is omitted when the importer is already owned by the package.
Hotell added 3 commits August 5, 2026 10:26
… honestly

The check reported "N fixtures free of tabster, @griffel/*, @fluentui/react-icons"
whenever nothing failed, which included the case where forbidden packages did
survive bundling and were merely allowlisted. It claimed a bundle was clean while
26 modules across three forbidden packages were in it.

Splits passing into two verdicts: PASS only when no forbidden package survived,
PASS WITH DEBT when every survivor is allowlisted. The debt case lists each
package with its module and export counts and the entry points dragging it in, so
the cleanup work is visible from the console instead of only in the JSON. Adds
--strict to reject allowlisted violations outright, for ratcheting.

Stale and orphaned allowlist entries now report as their own findings rather than
being folded into a generic failure, and a fixture can carry several findings at
once. Counts come from an unminified build, so they measure retention rather than
shipped bytes and are deliberately reported as modules, not kB.

Also writes summary.json unconditionally - it is the cheap artifact CI wants -
leaving --analyze to gate only the webpack-bundle-analyzer treemap and its
underlying report.json. Renames knownViolations to allowedViolations, since the
entries are tolerated debt rather than merely known.
…e isolation check

The check was a single 500 line file whose logic could only be exercised by
running webpack against the real package, so the two false-attribution bugs found
while building it were caught by hand rather than by a test.

Splits it along the seam that matters: config.js owns loading and path
conventions, report.js turns raw results into a verdict and renders it, and
cli.js does argument parsing and the webpack run. report.js touches neither
webpack nor the file system, so the verdict is testable directly - including the
regression test for the claim that a bundle is "free of" packages that are merely
allowlisted.

Adds 49 tests. bundle-isolation-plugin.spec.js bundles a purpose-built module
graph and asserts the attribution rules that were previously only verified by
inspection: an eliminated importer is not blamed, only used exports are named,
and a leak arriving through a dependency is traced back to the importing module.
Two things the fixture had to account for - webpack inlines constant exports and
drops the module, and macOS temp paths are symlinks that webpack reports resolved.

Also folds the manual pluralisation, badge padding and repeated path shortening
into shared helpers, renames single-letter sort parameters, and widens the Nx
target inputs to the whole script directory - bundle-isolation-plugin.js was
missing, so edits to the attribution logic did not bust the cache.
…tool

Nothing in the check was specific to the headless package - fixturesRoot,
externals, forbiddenPackages and allowedViolations are all config driven, and the
analysis was already a standalone webpack plugin. Living inside a component
package meant webpack, webpack-bundle-analyzer and ajv were implied dependencies
of a component library, and there was nowhere natural for its tests, since the
library's jest only covers src/.

Moves it to tools/verify-bundle-isolation as @fluentui/verify-bundle-isolation and
ports it to TypeScript. Follows the react-integration-tester and scripts-test-ssr
pattern of registerTsProject in the bin rather than a build step, so the check
gains no build dependency: consumers declare the tool as a devDependency pinned to
* and run `yarn run -T verify-bundle-isolation`.

The port removed the casts that JSDoc forced on the ajv import, but webpack does
not export RuntimeSpec, so it is recovered from a public signature instead.

Editor completions now come from a json.schemas mapping in .vscode/settings.json
rather than a $schema path in each config. Workspace packages hoist to the root
node_modules, so a package-relative $schema would have had to reach back up the
tree, and the schema's const on that value could not survive consumers at
different depths.
Hotell added 2 commits August 5, 2026 13:38
The tool declared webpack with an exact version to keep it identical to the one
behind the bundle-size numbers, which is the only reason its verdict means
anything. Switching to a caret range let yarn resolve 5.109.2 into a nested
node_modules while the rest of the repo stayed on 5.108.4, and 5.109 changed
module resolution such that workspace packages resolve to their sources instead
of built output - every fixture failed to bundle.

Keeps the caret and pins webpack through resolutions in the root package.json
instead, so a single version is enforced repo wide rather than per consumer.

Also replaces the target's hand written inputs with `default` and `^default`.
Overriding inputs wholesale had dropped Nx's dependency tracking, so the task was
served from cache after changes to the tool and, worse, after changes to any of
the packages whose built output it bundles. `^default` restores both, and makes
the devDependency on the tool load bearing rather than decorative.

Drops the eslint-plugin devDependency, since the lint target only covers src, and
adds an export map so the schema can be resolved by package name programmatically.
Editors resolve `$schema` relative to the config file without Node package
resolution, so consumers still need a workspace relative path there.
@Hotell Hotell changed the title chore(react-headless-components-preview): verify bundle isolation feat(verify-bundle-isolation): implement verify bundle isolation CLI and hook it to headless package Aug 5, 2026
@Hotell Hotell added the tools label Aug 5, 2026
Hotell added 2 commits August 5, 2026 15:08
…n TagPicker fixture

microsoft#36503 merged, adding TagPicker.fixture.js. It retains @griffel/core and
@griffel/react through the portal that ./tag-picker mounts, which the check
correctly reports as a regression because the fixture is new and nothing
allowlisted it.

Records it as tracked debt so the branch is green against current master. microsoft#36512
removes the leak, and the entry has to be deleted in the same change - the
allowlist is shrink-only, so a fixed leak keeps failing until its entry goes.

The same merge also shrank the AllComponents icon leak: react-icons now survives
only through ./teaching-popover, since the tag picker no longer ships a default
icon.
@Hotell
Hotell marked this pull request as ready for review August 5, 2026 14:52
@Hotell
Hotell requested review from a team as code owners August 5, 2026 14:52
@Hotell
Hotell requested review from dmytrokirpa and mainframev and a lite review from Copilot August 5, 2026 14:52

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

This PR introduces a new internal tool, @fluentui/verify-bundle-isolation, to bundle existing monosize *.fixture.js entries with webpack and fail CI when “forbidden” runtimes (e.g. Tabster, Griffel, React Icons) survive tree-shaking in packages that intend to be headless/isolation-safe. It wires the new verification target into react-headless-components-preview and adds it to the main PR validation workflow via nx affected -t verify-bundle-isolation.

Changes:

  • Added the @fluentui/verify-bundle-isolation CLI + webpack plugin, including config/schema, reporting, and Jest coverage.
  • Added verify-bundle-isolation Nx target + config to @fluentui/react-headless-components-preview, and enabled the target in PR CI.
  • Updated workspace dependency graph/lockfile to include the new tool and align webpack pinning.

Reviewed changes

Copilot reviewed 23 out of 25 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
yarn.lock Adds workspace entry for @fluentui/verify-bundle-isolation and related dependency resolution changes (Ajv/fast-uri/webpack-bundle-analyzer/webpack key).
tools/verify-bundle-isolation/tsconfig.spec.json Adds tool-local TS config for tests.
tools/verify-bundle-isolation/tsconfig.lib.json Adds tool-local TS config for library build/typechecking.
tools/verify-bundle-isolation/tsconfig.json Adds tool-local TS project references root.
tools/verify-bundle-isolation/src/report.ts Implements classification/verdict rendering + summary.json generation.
tools/verify-bundle-isolation/src/report.spec.ts Adds unit tests locking in verdict/report semantics.
tools/verify-bundle-isolation/src/config.ts Adds config loading/validation, fixture discovery, and path helpers.
tools/verify-bundle-isolation/src/config.spec.ts Adds tests for config validation + fixture discovery/path helpers.
tools/verify-bundle-isolation/src/cli.ts Implements CLI: arg parsing, webpack bundling per fixture, report output, and summary writing.
tools/verify-bundle-isolation/src/bundle-isolation-plugin.ts Adds webpack plugin that detects retained forbidden packages and attributes used exports/importers.
tools/verify-bundle-isolation/src/bundle-isolation-plugin.spec.ts Adds attribution-focused integration tests by bundling a synthetic module graph.
tools/verify-bundle-isolation/schema.json Adds JSON schema for per-package bundle isolation config.
tools/verify-bundle-isolation/README.md Documents tool purpose, usage, config format, verdicts, and outputs.
tools/verify-bundle-isolation/project.json Registers Nx project metadata for the tool.
tools/verify-bundle-isolation/package.json Declares the internal workspace tool package and CLI bin.
tools/verify-bundle-isolation/jest.config.js Adds Jest config for tool tests.
tools/verify-bundle-isolation/eslint.config.js Adds eslint flat config for the new tool package.
tools/verify-bundle-isolation/bin/verify-bundle-isolation.js Adds node bin entrypoint wiring TS registration + CLI invocation.
packages/react-components/react-headless-components-preview/library/project.json Adds verify-bundle-isolation target (cached, depends on build) to headless preview package.
packages/react-components/react-headless-components-preview/library/package.json Adds @fluentui/verify-bundle-isolation as a devDependency.
packages/react-components/react-headless-components-preview/library/eslint.config.js Adds eslint override to allow console/extraneous deps in node scripts (tooling-related).
packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json Adds package-specific forbidden list + allowlist debt tracking for fixtures.
package.json Adds @fluentui/verify-bundle-isolation to workspace deps and pins webpack (resolutions).
change/@fluentui-react-headless-components-preview-8a1f0c62-4d3e-47b5-9c0a-1f2e6b7d5a94.json Adds a change file noting the new verification behavior (type: none).
.github/workflows/pr.yml Adds verify-bundle-isolation to the nx affected PR validation target list.

Comment on lines +73 to +75

expect(findFixtures(root)).toEqual(['A.fixture.js', 'B.fixture.js', join('nested', 'C.fixture.js')]);
});
Comment on lines +42 to +45
return readdirSync(fixturesRoot, { recursive: true, withFileTypes: true })
.filter(entry => entry.isFile() && entry.name.endsWith(FIXTURE_SUFFIX))
.map(entry => join(entry.parentPath, entry.name).slice(fixturesRoot.length + 1))
.sort();
Comment on lines +161 to +164

if (importedIds(connection.dependency, moduleGraph)[0] === exportName) {
importers.set(origin, connection.originModule);
}
Comment on lines +111 to +115
- `fixturesRoot` is the directory containing bundle-size fixtures.
- `externals` lists host-provided modules excluded from the bundle.
- `forbiddenPackages` lists exact package names or scoped globs such as `@griffel/*`.
- `allowedViolations` maps fixture paths, relative to `fixturesRoot`, to tolerated forbidden packages.

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.

2 participants