-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(verify-bundle-isolation): implement verify bundle isolation CLI and hook it to headless package #36511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Martin Hochel (Hotell)
merged 16 commits into
microsoft:master
from
Hotell:feat/headless-bundle-isolation
Aug 6, 2026
Merged
feat(verify-bundle-isolation): implement verify bundle isolation CLI and hook it to headless package #36511
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
a4f6cb5
chore(react-headless-components-preview): add bundle isolation verifi…
Hotell a35dd1e
refactor(react-headless-components-preview): configure bundle isolation
Hotell ccd95cc
ci: verify headless bundle isolation in PRs
Hotell 5efc062
refactor(react-headless-components-preview): verify bundle isolation …
Hotell 2d26783
docs(react-headless-components-preview): tighten bundle isolation readme
Hotell 6ff6f3f
feat(react-headless-components-preview): trace bundle leaks back to t…
Hotell c2ca19c
feat(react-headless-components-preview): report bundle isolation debt…
Hotell 00c97ea
refactor(react-headless-components-preview): split and test the bundl…
Hotell 9b47c28
refactor(verify-bundle-isolation): extract the check into a reusable …
Hotell e8bd343
fix(verify-bundle-isolation): pin webpack and fix cache invalidation
Hotell 5b55d29
chore: yarn dedupe
Hotell 315a627
Merge remote-tracking branch 'upstream/master' into feat/headless-bun…
Hotell 70250a2
chore(react-headless-components-preview): allowlist the portal leak i…
Hotell 9e558b6
fix(verify-bundle-isolation): make fixture keys cross-platform and pi…
Hotell e5a57e8
Merge remote-tracking branch 'upstream/master' into feat/headless-bun…
Hotell 676bad1
chore(react-headless-components-preview): lock in the react-icons fix
Hotell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
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
7 changes: 7 additions & 0 deletions
7
change/@fluentui-react-headless-components-preview-8a1f0c62-4d3e-47b5-9c0a-1f2e6b7d5a94.json
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "type": "none", | ||
| "comment": "chore: verify headless entry points do not bundle tabster, Griffel or react-icons", | ||
| "packageName": "@fluentui/react-headless-components-preview", | ||
| "email": "martinhochel@microsoft.com", | ||
| "dependentChangeType": "none" | ||
| } |
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
10 changes: 10 additions & 0 deletions
10
...s/react-components/react-headless-components-preview/library/bundle-isolation.config.json
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "$schema": "../../../../tools/verify-bundle-isolation/schema.json", | ||
| "fixturesRoot": "./bundle-size", | ||
| "externals": ["react", "react-dom", "react/jsx-runtime", "react/compiler-runtime"], | ||
| "forbiddenPackages": ["tabster", "@griffel/*", "@fluentui/react-icons"], | ||
| "allowedViolations": { | ||
| "AllComponents.fixture.js": ["@griffel/core", "@griffel/react"], | ||
| "TagPicker.fixture.js": ["@griffel/core", "@griffel/react"] | ||
| } | ||
| } |
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
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| # @fluentui/verify-bundle-isolation | ||
|
|
||
| Fails when a bundle-size fixture retains a runtime a package is meant to stay free of, such as a styling engine or icon | ||
| set that should have been tree shaken away. | ||
|
|
||
| ## How it works | ||
|
|
||
| Each `*.fixture.js` is bundled with webpack — the same bundler behind the bundle-size numbers — and the resulting module | ||
| graph is inspected. A forbidden package that survives tree shaking is reported with the exports that kept it alive, the | ||
| modules importing them, and the module in the package under test that pulled those modules in: | ||
|
|
||
| ``` | ||
| REGRESSION AllComponents.fixture.js - 1 forbidden package not on the allowlist | ||
| @griffel/core - 11 modules retained | ||
| mergeClasses | ||
| <- .../react-portal/lib/components/Portal/usePortalMountNode.js (via lib/tag-picker.js) | ||
| ``` | ||
|
|
||
| `via` matters when a leak arrives through a dependency: above, nothing imports `react-portal` directly — | ||
| `lib/tag-picker.js` re-exports a render function that mounts a portal, which is what drags Griffel in. | ||
|
|
||
| Attribution intersects webpack's `usedExports` with active import connections, and counts an importer only when that | ||
| module itself survived into a chunk. Import edges are recorded before tree shaking, so a module importing something it | ||
| no longer uses is not reported. | ||
|
|
||
| ## Usage | ||
|
|
||
| Add the tool as a devDependency of the package to check and give it a target: | ||
|
|
||
| ```jsonc | ||
| // package.json | ||
| { "devDependencies": { "@fluentui/verify-bundle-isolation": "*" } } | ||
| ``` | ||
|
|
||
| ```jsonc | ||
| // project.json | ||
| { | ||
| "targets": { | ||
| "verify-bundle-isolation": { | ||
| "cache": true, | ||
| "dependsOn": ["build", "^build"], | ||
| "command": "yarn run -T verify-bundle-isolation", | ||
| "options": { "cwd": "{projectRoot}" }, | ||
| "inputs": ["default", "^default", { "externalDependencies": ["ajv", "webpack"] }], | ||
| "outputs": ["{projectRoot}/dist/bundle-isolation"] | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| The check must run against built output, hence `dependsOn`. It reports an error if bundling resolves to package sources | ||
| instead, because the verdict would not reflect what ships. | ||
|
|
||
| `^default` is what makes the cache correct: this task's result depends on every dependency's files, and on the tool | ||
| itself, which is a dependency by virtue of the devDependency. Replacing it with a hand-written input list silently | ||
| serves stale verdicts after a dependency changes. | ||
|
|
||
| The repo pins webpack to a single version through `resolutions` in the root `package.json`. That is deliberate - the | ||
| verdict is only meaningful if it comes from the same bundler that produces the bundle-size numbers, and webpack 5.109 | ||
| changed module resolution in a way that makes these packages resolve to sources rather than built output. | ||
|
|
||
| | Flag | Default | Description | | ||
| | ----------------- | ------------------------------ | -------------------------------------------------------------------- | | ||
| | `--config <path>` | `bundle-isolation.config.json` | Configuration file, resolved from the working directory | | ||
| | `--analyze` | off | Also write webpack-bundle-analyzer artifacts per fixture | | ||
| | `--strict` | off | Fail on allowed violations too, so the allowlist cannot be relied on | | ||
|
|
||
| ## Verdicts | ||
|
|
||
| | Verdict | Exit | Meaning | | ||
| | ---------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `PASS` | 0 | No forbidden package survived bundling. Only this verdict claims a bundle is free of them. | | ||
| | `PASS WITH DEBT` | 0 | Every surviving forbidden package is on the allowlist. The leaks are listed with their module counts and entry points. | | ||
| | `FAIL` | 1 | A regression, a stale or orphaned allowlist entry, a fixture that failed to bundle, or — under `--strict` — any allowed violation. | | ||
|
|
||
| Per fixture the report labels each finding `CLEAN`, `ALLOWED`, `REGRESSION`, `STALE` or `ERROR`; a single fixture can | ||
| carry more than one label. Module and export counts come from a build with `minimize: false`, so they measure how much | ||
| of a package is retained, not what it costs to ship — use monosize for bytes. | ||
|
|
||
| ## Output | ||
|
|
||
| `dist/bundle-isolation/` is wiped on every run, so it only ever contains the fixtures that currently exist. | ||
|
|
||
| | Path | Written | Contents | | ||
| | ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `summary.json` | always | The console verdict in structured form — overall `status`, and per fixture its `status`, `allowedViolations`, `tolerated`, `regressions`, `stale` and full `leaks` map | | ||
| | `<Fixture>/report.html` | with `--analyze` | webpack-bundle-analyzer treemap | | ||
| | `<Fixture>/report.json` | with `--analyze` | The same data the treemap renders from — module tree with `statSize`, `parsedSize` and `gzipSize` | | ||
|
|
||
| `leaks` maps a forbidden package to the exports that survived tree shaking and the modules importing them, so the | ||
| summary answers _what_ leaked and _why_, while the analyzer output answers _how much_ it costs. | ||
|
|
||
| ## Configuration | ||
|
|
||
| `bundle-isolation.config.json` in the package root, validated against [`schema.json`](./schema.json). | ||
|
|
||
| ```json | ||
| { | ||
| "$schema": "../../../../tools/verify-bundle-isolation/schema.json", | ||
| "fixturesRoot": "./bundle-size", | ||
| "externals": ["react", "react-dom", "react/jsx-runtime", "react/compiler-runtime"], | ||
| "forbiddenPackages": ["tabster", "@griffel/*", "@fluentui/react-icons"], | ||
| "allowedViolations": { | ||
| "AllComponents.fixture.js": ["@fluentui/react-icons"] | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| All configured paths are resolved relative to the package root: | ||
|
|
||
| - `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` and always with forward slashes, to tolerated | ||
| forbidden packages. | ||
|
|
||
| The two lists do not take the same values. `forbiddenPackages` declares intent, so it accepts globs. `allowedViolations` | ||
| records what actually leaked, so it takes **exact resolved package names** and rejects globs - `@griffel/*` there would | ||
| let a newly leaked `@griffel/anything` hide behind an entry approved for something else. The debt has to name what it | ||
| is: `@griffel/core` and `@griffel/react`, separately. | ||
|
|
||
| `$schema` has to be a workspace-relative path. Editors resolve it against the config file and do not apply Node package | ||
| resolution, so `@fluentui/verify-bundle-isolation/schema.json` will not work there despite the export map. The export | ||
| map exists for programmatic consumers, which can `require.resolve('@fluentui/verify-bundle-isolation/schema.json')`. | ||
|
|
||
| Validation itself never depends on `$schema` - the CLI always loads the schema shipped alongside it. | ||
|
|
||
| ## Fixtures | ||
|
|
||
| Fixtures follow the existing monosize convention in `bundle-size/*.fixture.js`. A fixture imports the public API under | ||
| test and uses the import observably so tree shaking cannot discard it. | ||
|
|
||
| ```js | ||
| import * as Button from '@scope/package/button'; | ||
|
|
||
| console.log(Button); | ||
|
|
||
| export default { | ||
| name: 'Button', | ||
| }; | ||
| ``` | ||
|
|
||
| Sharing fixtures keeps isolation checks and bundle-size measurements aligned. | ||
|
|
||
| ## Allowed violations | ||
|
|
||
| `allowedViolations` is tracked debt, not an exemption. It is shrink-only: | ||
|
|
||
| - A newly retained forbidden package fails the check. | ||
| - A package that no longer survives bundling also fails the check until its entry is removed. | ||
| - An entry for a missing fixture fails the check. | ||
|
|
||
| This prevents fixed leaks from being silently reintroduced. Deleting an entry is the goal; adding one is a regression. | ||
|
|
||
| ## Layout | ||
|
|
||
| | File | Responsibility | | ||
| | -------------------------------- | ------------------------------------------------------------------------------------- | | ||
| | `src/bundle-isolation-plugin.ts` | The analysis — which forbidden packages survived, and why. A standard webpack plugin. | | ||
| | `src/config.ts` | Configuration loading, fixture discovery, path conventions | | ||
| | `src/report.ts` | Turns raw results into a verdict and renders it. No webpack, no file system. | | ||
| | `src/cli.ts` | Argument parsing and the webpack run that feeds the above | | ||
|
|
||
| Keeping `report.ts` free of webpack and I/O is what makes the verdict testable without bundling anything; | ||
| `bundle-isolation-plugin.spec.ts` covers attribution by bundling a purpose-built module graph. | ||
|
|
||
| ## Reuse in another build | ||
|
|
||
| The analysis is a standard webpack plugin, so it can run inside an existing build instead of the one the CLI creates: | ||
|
|
||
| ```ts | ||
| new BundleIsolationPlugin({ forbiddenPackages, workspaceRoot, packageRoot, onReport }); | ||
| ``` | ||
|
|
||
| `packageRoot` is optional and only powers the `via` origin. | ||
|
|
||
| It requires `optimization.concatenateModules: false`, because scope hoisting merges modules into a `ConcatenatedModule` | ||
| with no per-module `resource`. |
15 changes: 15 additions & 0 deletions
15
tools/verify-bundle-isolation/bin/verify-bundle-isolation.js
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| #!/usr/bin/env node | ||
|
|
||
| // @ts-check | ||
|
|
||
| const { joinPathFragments } = require('@nx/devkit'); | ||
| const { registerTsProject } = require('@nx/js/src/internal'); | ||
|
|
||
| registerTsProject(joinPathFragments(__dirname, '..', 'tsconfig.lib.json')); | ||
|
|
||
| const { cli } = require('../src/cli'); | ||
|
|
||
| cli().catch(error => { | ||
| console.error(error); | ||
| process.exit(1); | ||
| }); |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| // @ts-check | ||
| const fluentPlugin = require('@fluentui/eslint-plugin'); | ||
|
|
||
| /** @type {import("eslint").Linter.Config[]} */ | ||
| module.exports = [ | ||
| ...fluentPlugin.configs['flat/node'], | ||
| ...fluentPlugin.configs['flat/imports'], | ||
| { | ||
| rules: { | ||
| 'import/no-extraneous-dependencies': [ | ||
| 'error', | ||
| { | ||
| packageDir: ['.', '../../'], | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
| ]; |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| // @ts-check | ||
|
|
||
| /** | ||
| * @type {import('@jest/types').Config.InitialOptions} | ||
| */ | ||
| module.exports = { | ||
| displayName: 'verify-bundle-isolation', | ||
| preset: '../../jest.preset.js', | ||
| transform: { | ||
| '^.+\\.tsx?$': ['@swc/jest', {}], | ||
| }, | ||
| coverageDirectory: './coverage', | ||
| testEnvironment: 'node', | ||
| }; |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| { | ||
| "name": "@fluentui/verify-bundle-isolation", | ||
| "version": "0.0.1", | ||
| "description": "Asserts that a package's bundle-size fixtures do not bundle forbidden runtimes", | ||
| "private": true, | ||
| "type": "commonjs", | ||
| "bin": "./bin/verify-bundle-isolation.js", | ||
| "exports": { | ||
| "./schema.json": "./schema.json", | ||
| "./package.json": "./package.json" | ||
| }, | ||
| "dependencies": { | ||
| "ajv": "^8.13.0", | ||
| "webpack": "^5.108.4", | ||
| "webpack-bundle-analyzer": "^4.10.1" | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "name": "verify-bundle-isolation", | ||
| "$schema": "../../node_modules/nx/schemas/project-schema.json", | ||
| "sourceRoot": "tools/verify-bundle-isolation/src", | ||
| "projectType": "library", | ||
| "tags": ["platform:node", "tools"] | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| { | ||
| "$schema": "http://json-schema.org/draft-07/schema", | ||
| "title": "Bundle isolation configuration", | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "properties": { | ||
| "$schema": { | ||
| "description": "Path to this schema, relative to the configuration file.", | ||
| "type": "string" | ||
| }, | ||
| "fixturesRoot": { | ||
| "description": "Package-relative directory containing bundle-size fixtures.", | ||
| "type": "string", | ||
| "minLength": 1 | ||
| }, | ||
| "externals": { | ||
| "description": "Modules supplied by the consuming application rather than this bundle.", | ||
| "type": "array", | ||
| "uniqueItems": true, | ||
| "items": { | ||
| "type": "string", | ||
| "minLength": 1 | ||
| } | ||
| }, | ||
| "forbiddenPackages": { | ||
| "description": "Package names or scoped package globs that must not survive tree shaking.", | ||
| "type": "array", | ||
| "minItems": 1, | ||
| "uniqueItems": true, | ||
| "items": { | ||
| "type": "string", | ||
| "minLength": 1 | ||
| } | ||
| }, | ||
| "allowedViolations": { | ||
| "description": "Bundle-size fixture paths mapped to forbidden packages tolerated as tracked debt. Fixture paths use forward slashes and are relative to fixturesRoot.", | ||
| "type": "object", | ||
| "additionalProperties": { | ||
| "type": "array", | ||
| "uniqueItems": true, | ||
| "items": { | ||
| "description": "Exact resolved package name. Globs are rejected so a tolerated leak cannot silently cover a new one.", | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "pattern": "^[^*]+$" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "required": ["fixturesRoot", "externals", "forbiddenPackages", "allowedViolations"] | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.