feat(verify-bundle-isolation): implement verify bundle isolation CLI and hook it to headless package - #36511
Open
Hotell wants to merge 13 commits into
Open
feat(verify-bundle-isolation): implement verify bundle isolation CLI and hook it to headless package#36511Hotell wants to merge 13 commits into
Hotell wants to merge 13 commits into
Conversation
📊 Bundle size report✅ No changes found |
|
Pull request demo site: URL |
…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.
… 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.
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.
…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
marked this pull request as ready for review
August 5, 2026 14:52
Hotell
requested review from
dmytrokirpa and
mainframev
and
a lite review from Copilot
August 5, 2026 14:52
Contributor
There was a problem hiding this comment.
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-isolationCLI + webpack plugin, including config/schema, reporting, and Jest coverage. - Added
verify-bundle-isolationNx 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. | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 vianx affected -t verify-bundle-isolation.Three verdicts, so the result is never overstated:
--strictalso fails on allowlisted leaks.Current output for this package:
viapoints at the module in this package responsible: nothing importsreact-portaldirectly, but./tag-pickerre-exports a render function that mounts a portal. Both leaks are already tracked — #36503 and #36504.allowedViolationsis 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
viachain led to #36512.react-portalpulls in all 17 Griffel modules to apply five static CSS declarations —makeStylesfor the mount node plusmergeClassesto 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-iconsbarrel. A package is reported only when its modules are in an emitted chunk, an export is inusedExports, and the importing module also survived. Dropping any one condition produces false positives such as blamingreact-avatarforPersonRegular, 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.tsbundles a purpose-built module graph and locks in the attribution rules above — including that an eliminated importer is not blamed.report.spec.tsasserts 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,
PASSreached by narrowingforbiddenPackagesto an absent package, and detection cross-checked against the emitted monosize bundle.Notes for reviewers
--analyzeadditionally writes a treemap per fixture;summary.jsonis always written for CI.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.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)