Skip to content
Merged
Show file tree
Hide file tree
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 Aug 4, 2026
a35dd1e
refactor(react-headless-components-preview): configure bundle isolation
Hotell Aug 4, 2026
ccd95cc
ci: verify headless bundle isolation in PRs
Hotell Aug 4, 2026
5efc062
refactor(react-headless-components-preview): verify bundle isolation …
Hotell Aug 4, 2026
2d26783
docs(react-headless-components-preview): tighten bundle isolation readme
Hotell Aug 4, 2026
6ff6f3f
feat(react-headless-components-preview): trace bundle leaks back to t…
Hotell Aug 4, 2026
c2ca19c
feat(react-headless-components-preview): report bundle isolation debt…
Hotell Aug 5, 2026
00c97ea
refactor(react-headless-components-preview): split and test the bundl…
Hotell Aug 5, 2026
9b47c28
refactor(verify-bundle-isolation): extract the check into a reusable …
Hotell Aug 5, 2026
e8bd343
fix(verify-bundle-isolation): pin webpack and fix cache invalidation
Hotell Aug 5, 2026
5b55d29
chore: yarn dedupe
Hotell Aug 5, 2026
315a627
Merge remote-tracking branch 'upstream/master' into feat/headless-bun…
Hotell Aug 5, 2026
70250a2
chore(react-headless-components-preview): allowlist the portal leak i…
Hotell Aug 5, 2026
9e558b6
fix(verify-bundle-isolation): make fixture keys cross-platform and pi…
Hotell Aug 6, 2026
e5a57e8
Merge remote-tracking branch 'upstream/master' into feat/headless-bun…
Hotell Aug 6, 2026
676bad1
chore(react-headless-components-preview): lock in the react-icons fix
Hotell Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:

Comment thread
Hotell marked this conversation as resolved.
- name: build, test, lint, test-ssr (affected)
run: |
FLUENT_JEST_WORKER=2 yarn nx affected -t build test lint type-check test-ssr test-integration verify-packaging --nxBail
FLUENT_JEST_WORKER=2 yarn nx affected -t build test lint type-check test-ssr test-integration verify-packaging verify-bundle-isolation --nxBail

- name: 'Check for unstaged changes'
run: |
Expand Down
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"
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"@fluentui/react-integration-tester": "*",
"@fluentui/scripts-test-ssr": "*",
"@fluentui/storybook-llms-extractor": "*",
"@fluentui/verify-bundle-isolation": "*",
"@griffel/babel-preset": "1.5.8",
"@griffel/eslint-plugin": "^2.0.0",
"@griffel/jest-serializer": "1.1.24",
Expand Down Expand Up @@ -381,6 +382,7 @@
"swc-loader": "0.2.6",
"syncpack/minimatch": "^9.0.7",
"tar-fs": "2.1.4",
"webpack": "5.108.4",
"ws": "^8.21.1"
},
"nx": {
Expand Down
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"]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,14 @@

const fluentPlugin = require('@fluentui/eslint-plugin');

module.exports = [...fluentPlugin.configs['flat/react']];
module.exports = [
...fluentPlugin.configs['flat/react'],
{
// Build-time verification tooling - not shipped, runs on Node, reports via stdout.
files: ['scripts/**/*.js'],
rules: {
'no-console': 'off',
'import/no-extraneous-dependencies': 'off',
},
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@
},
"devDependencies": {
"@fluentui/scripts-cypress": "*",
"@fluentui/verify-bundle-isolation": "*",
"@oddbird/popover-polyfill": "^0.6.1"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@
"options": {
"exportSubpaths": true
}
},
"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"],
"metadata": {
"technologies": ["webpack"],
"description": "Assert entry points do not bundle tabster, Griffel or react-icons"
}
}
}
}
178 changes: 178 additions & 0 deletions tools/verify-bundle-isolation/README.md
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 tools/verify-bundle-isolation/bin/verify-bundle-isolation.js
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);
});
18 changes: 18 additions & 0 deletions tools/verify-bundle-isolation/eslint.config.js
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: ['.', '../../'],
},
],
},
},
];
14 changes: 14 additions & 0 deletions tools/verify-bundle-isolation/jest.config.js
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',
};
17 changes: 17 additions & 0 deletions tools/verify-bundle-isolation/package.json
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"
}
}
7 changes: 7 additions & 0 deletions tools/verify-bundle-isolation/project.json
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"]
}
51 changes: 51 additions & 0 deletions tools/verify-bundle-isolation/schema.json
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"]
}
Loading
Loading