Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 42 additions & 0 deletions packages/docsite/stories/Icons/IconsBuildTransforms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,48 @@ Pass the `iconVariant` option to target a different implementation (`'svg'` —
}
```

The default `moduleGranularity: 'family'` mode already tree-shakes unused icon exports. Set `moduleGranularity: 'icon'` when you need finer chunk distribution: this opt-in mode gives every selected icon export its own module identity, allowing the bundler to place icons independently instead of moving an entire icon family between chunks:

```js
{
loader: '@fluentui/react-icons-atomic-webpack-loader',
options: { moduleGranularity: 'icon' },
}
```

Keep the loader rule active for generated Fluent icon atoms in `node_modules`; the loader processes those ESM files a second time to emit the selected per-icon modules. Font and SVG-sprite variants also require their matching subsetting plugin.

Icon granularity can be scoped by loader rule. For example, an icon picker that intentionally uses many icons can retain family granularity:

```js
const path = require('path');

const iconPicker = path.resolve(__dirname, 'src/icon-picker');
const fluentAtoms = /node_modules[\\/]@fluentui[\\/]react-(?:brand-)?icons[\\/]lib[\\/]atoms[\\/]/;

module.exports = {
module: {
rules: [
{
test: /\.[mc]?[jt]sx?$/,
include: [path.resolve(__dirname, 'src'), fluentAtoms],
exclude: iconPicker,
loader: '@fluentui/react-icons-atomic-webpack-loader',
options: { moduleGranularity: 'icon' },
},
{
test: /\.[mc]?[jt]sx?$/,
include: iconPicker,
loader: '@fluentui/react-icons-atomic-webpack-loader',
options: { moduleGranularity: 'family' },
},
],
},
};
```

Keep `fluentAtoms` in the icon-mode rule because query-selected requests require the loader's second pass; unqueried family modules pass through unchanged. A picker that needs every icon can instead lazy-load the unatomized barrel so the complete set stays in its own async chunk.

### 2. Font subsetting plugin

When you use the font variant (`iconVariant: 'fonts'`), pair the loader with the font subsetting plugin so only the glyphs used by your build are shipped:
Expand Down
148 changes: 148 additions & 0 deletions packages/react-icons-atomic-webpack-loader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,151 @@ module.exports = {
| `fallbackVariant` | `'svg'` \| `'fonts'` \| `'svg-sprite'` | `undefined` | Variant used for a module that does not support `iconVariant` (see below). |
| `headless` | `boolean` | `false` | Resolve to the headless (Griffel-free) build where the module ships one. |
| `allowDynamicImports` | `boolean` | `false` | Atomize a narrow, statically-provable subset of dynamic `import()` barrels (see below). |
| `moduleGranularity` | `'family'` \| `'icon'` | `'family'` | Give each selected icon export its own bundler module identity (see below). |

### Export-level module granularity

`moduleGranularity: 'icon'` appends an internal, versioned resource query to
each icon-family request. The loader then processes the resolved generated ESM
family a second time and emits only the selected declaration plus its directives
and imports. This gives SplitChunks independently placeable icon modules without
publishing one physical file per export:

```js
{
loader: '@fluentui/react-icons-atomic-webpack-loader',
options: { moduleGranularity: 'icon' },
}
```

The loader rule must cover every source file whose Fluent barrel imports should
be rewritten, plus the generated ESM atom files under
`@fluentui/react-icons` and `@fluentui/react-brand-icons`. To rewrite imports
inside arbitrary third-party packages, omit `include` so matching JavaScript
and TypeScript throughout the dependency graph are processed. The loader's
source-text pre-check cheaply skips files that do not reference a supported
Fluent icon package.

For the lowest rule-matching overhead, applications that only rewrite their
own source and a known set of dependencies can use a targeted include:

```js
const path = require('path');

{
test: /\.[mc]?[jt]sx?$/,
include: [
Comment thread
Hotell marked this conversation as resolved.
path.resolve(__dirname, 'src'),
path.dirname(require.resolve('known-dependency/package.json')),
/node_modules[\\/]@fluentui[\\/]react-(?:brand-)?icons[\\/]lib[\\/]atoms[\\/]/,
],
enforce: 'pre',
use: [
{
loader: '@fluentui/react-icons-atomic-webpack-loader',
options: { moduleGranularity: 'icon' },
},
],
}
```

The first pass rewrites application imports to query-addressed family
requests. The bundler resolves each request to a physical generated atom file,
then applies the same loader rule again to emit the selected virtual module.
Webpack and Rspack do not interpret the selector query themselves. If the atom
directory is excluded, the second pass cannot run and the full physical family
source is loaded under the queried identity. The loader cannot diagnose that
misconfiguration because it is never invoked for that resource.

Every third-party importer that should be rewritten must be represented in the
targeted list; otherwise use the comprehensive rule without `include`.
Utilities, providers, and helper modules remain canonical and unqueried.

#### Opt out high-cardinality application areas

`moduleGranularity` is selected per loader rule, so an application can use icon
granularity for most source files while retaining family granularity for an
area that intentionally uses many or all icons, such as an icon picker. This
avoids creating thousands of virtual module and cache entries where independent
chunk placement provides little benefit:

```js
const path = require('path');

const loader = require.resolve('@fluentui/react-icons-atomic-webpack-loader');
const appSource = path.resolve(__dirname, 'src');
const iconPickerSource = path.resolve(appSource, 'icon-picker');
const fluentAtoms = /node_modules[\\/]@fluentui[\\/]react-(?:brand-)?icons[\\/]lib[\\/]atoms[\\/]/;

module.exports = {
module: {
rules: [
{
test: /\.[mc]?[jt]sx?$/,
include: [appSource, fluentAtoms],
exclude: iconPickerSource,
enforce: 'pre',
loader,
options: { moduleGranularity: 'icon' },
},
{
test: /\.[mc]?[jt]sx?$/,
include: iconPickerSource,
enforce: 'pre',
loader,
options: { moduleGranularity: 'family' },
},
],
},
};
```

The generated atom directory stays in the icon rule because query-selected
requests need the loader's second pass. The `moduleGranularity` option is not
consulted during that pass: the `__fluentIcon` query already identifies the
selection request. Unqueried atom modules produced by the family-mode rule pass
through unchanged.

For a runtime picker that truly needs the complete export set, placing an
unatomized `import('@fluentui/react-icons')` behind a lazy boundary keeps that
cost in the picker's async chunk. Dynamic barrel imports are intentionally not
expanded into thousands of selected modules.

Direct named family imports are selected without changing their explicit
strategy, so `/svg/add` stays SVG even when `iconVariant: 'fonts'` is configured.
Namespace/default direct imports and imports whose family membership cannot be
proven retain family behavior with a warning. CommonJS resources remain
family-level; malformed or stale selector queries fail the build.

Font and SVG-sprite icon granularity requires compatible releases of the
corresponding subsetting plugin. A compilation-level protocol handshake fails
closed if the plugin is absent or query-unaware. Revert to
`moduleGranularity: 'family'` for immediate rollback.

Each selected export becomes a module-graph and persistent-cache entry, and each
selected React Server Component module repeats its `"use client"` directive.
Measure cold/warm build time, peak memory, cache size, module count, and route
ownership before rollout. SplitChunks rules matching icon atom resources should
account for `resourceQuery` rather than assuming one family module.

The loader skips source-map generation when the bundler disables source maps.
When enabled, importer and selector maps are composed with any incoming map.
Repository contributors can run the repeatable 5,000-export microbenchmark with:

```sh
yarn workspace @fluentui/react-icons-atomic-webpack-loader benchmark
```

Override its scale with `ICON_BENCHMARK_EXPORTS` and
`ICON_BENCHMARK_ITERATIONS`. The benchmark reports fast-skip and importer
rewrite timing, source-map cost, selector emissions, unique physical parses,
cache hits, cache entries, and RSS change (the process's resident memory at the
end of a scenario minus its resident memory at the start). It measures loader work only;
consumer validation must additionally record bundler module counts,
persistent-cache size, and route ownership.

Set `ICON_BENCHMARK_JSON=1` for machine-readable output. The benchmark is not a
CI gate because wall-clock and RSS measurements vary across shared runners.

### Variant resolution & `fallbackVariant`

Expand Down Expand Up @@ -252,6 +397,9 @@ Files that don't reference a supported module are passed through untouched (fast

## Limitations

Export-level selection applies to generated ESM atoms only. Existing unqueried
CommonJS deep imports continue to use family-level behavior.

### Dynamic imports are not atomized

The loader only rewrites **static** `import` / `export … from` declarations. A dynamic `import()` of a barrel cannot be atomized, because the returned module-namespace object is a runtime value whose usage the loader cannot statically prove:
Expand Down
4 changes: 3 additions & 1 deletion packages/react-icons-atomic-webpack-loader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"test": "yarn run -T vitest run && yarn run test:types && node test/run.js --bundler all",
"test:types": "yarn run -T tsc -p test/tsconfig.conformance.json",
"test:webpack": "node test/run.js --bundler webpack",
"test:rspack": "node test/run.js --bundler rspack"
"test:rspack": "node test/run.js --bundler rspack",
"benchmark": "yarn build && node test/benchmark.js"
},
"engines": {
"node": ">=20.0.0"
Expand All @@ -24,6 +25,7 @@
"url": "https://github.com/microsoft/fluentui-system-icons/issues"
},
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"magic-string": "^0.30.0",
"oxc-parser": "^0.125.0"
},
Expand Down
28 changes: 28 additions & 0 deletions packages/react-icons-atomic-webpack-loader/src/direct-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { getIconFamilyName } from './selector-protocol';

export interface DirectIconPath {
family: string;
variant: 'svg' | 'fonts' | 'svg-sprite';
}

export function classifyDirectIconPath(request: string): DirectIconPath | null {
const match = /^@fluentui\/(react-icons|react-brand-icons)\/(headless\/)?(svg|fonts|svg-sprite)\/([\w-]+)$/.exec(
request,
);
if (!match) {
return null;
}

const [, packageName, headless, variant, family] = match;
if (packageName === 'react-brand-icons' && variant !== 'svg') {
return null;
}
if (headless && variant === 'svg-sprite') {
return null;
}
return { family, variant: variant as DirectIconPath['variant'] };
}

export function belongsToDirectIconPath(exportName: string, directPath: DirectIconPath): boolean {
return getIconFamilyName(exportName) === directPath.family;
}
79 changes: 74 additions & 5 deletions packages/react-icons-atomic-webpack-loader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { transformSource } from './transform';
import { SUPPORTED_MODULE_NAMES } from './modules';
import type { IconVariant } from './modules';
import type { AtomicLoaderContext } from './loader-context';
import { selectExports } from './select-export';
import { composeSourceMaps, type SourceMapInput } from './source-maps';
import {
assertSelectableResource,
getRegisteredSelectorCapabilities,
getSelectorCapability,
parseSelectorQuery,
SELECTOR_PROTOCOL_IDENTIFIER,
} from './selector-protocol';

export type { IconVariant };
export type { AtomicLoaderContext };
Expand Down Expand Up @@ -62,18 +71,56 @@ export interface FluentIconsAtomicImportLoaderOptions {
* (`import('./icons')`) over relying on this; see the README for the gotchas.
*/
allowDynamicImports?: boolean;
/**
* Module graph granularity for icon implementations. `"family"` preserves the
* existing family-module behavior. `"icon"` emits query-addressed per-export
* modules for independently placeable chunks.
*/
moduleGranularity?: 'family' | 'icon';
}

export default function fluentIconsAtomicImportLoader(this: AtomicLoaderContext, sourceCode: string): void {
const { resourcePath } = this;
export default function fluentIconsAtomicImportLoader(
this: AtomicLoaderContext,
sourceCode: string,
inputSourceMap?: SourceMapInput,
): void {
const { resourcePath, resourceQuery = '' } = this;
const generateSourceMap = this.sourceMap !== false;
const passThroughSourceMap = generateSourceMap ? inputSourceMap : undefined;

try {
const selector = parseSelectorQuery(resourceQuery);
if (selector) {
assertSelectableResource(resourcePath, selector);
assertPluginCapability(this, resourcePath);
const selected = selectExports(sourceCode, resourcePath, selector, generateSourceMap);
const map = composeSourceMaps(selected.map, inputSourceMap);
return this.callback(null, selected.code, map);
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return this.callback(
new Error(`FluentIconsAtomicImportLoader: Failed to select "${resourcePath}${resourceQuery}": ${reason}`),
);
}

if (isGeneratedIconPackageResource(resourcePath)) {
return this.callback(null, sourceCode, passThroughSourceMap);
}

// Cheap pre-skip only: a false positive here just means we parse the file and
// let the module record decide. Diagnostics are driven by actual imports.
if (!SUPPORTED_MODULE_NAMES.some((name) => sourceCode.includes(name))) {
return this.callback(null, sourceCode);
return this.callback(null, sourceCode, passThroughSourceMap);
}

const { iconVariant = 'svg', fallbackVariant, headless = false, allowDynamicImports = false } = this.getOptions();
const {
iconVariant = 'svg',
fallbackVariant,
headless = false,
allowDynamicImports = false,
moduleGranularity = 'family',
} = this.getOptions();

let code: string;
let map: ReturnType<typeof transformSource>['map'];
Expand All @@ -85,6 +132,8 @@ export default function fluentIconsAtomicImportLoader(this: AtomicLoaderContext,
fallbackVariant,
headless,
allowDynamicImports,
moduleGranularity,
sourceMap: generateSourceMap,
path: resourcePath,
}));
} catch (error) {
Expand All @@ -103,5 +152,25 @@ export default function fluentIconsAtomicImportLoader(this: AtomicLoaderContext,
return this.callback(new Error(`FluentIconsAtomicImportLoader: ${firstError.message}`));
}

return this.callback(null, code, map);
return this.callback(null, code, composeSourceMaps(map, inputSourceMap));
}

function isGeneratedIconPackageResource(resourcePath: string): boolean {
const normalized = resourcePath.replace(/\\/g, '/');
return /\/react-(?:brand-)?icons\/lib(?:-cjs)?\//.test(normalized);
}

function assertPluginCapability(context: AtomicLoaderContext, resourcePath: string): void {
const capability = getSelectorCapability(resourcePath);
if (!capability) {
return;
}

const capabilities = context._compilation ? getRegisteredSelectorCapabilities(context._compilation) : undefined;
if (!capabilities?.has(capability)) {
throw new Error(
`"${capability}" icon selection requires a query-aware subsetting plugin supporting ` +
`selector protocol "${SELECTOR_PROTOCOL_IDENTIFIER}"`,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import type { FluentIconsAtomicImportLoaderOptions } from './index';
*/
export interface AtomicLoaderContext {
readonly resourcePath: string;
readonly resourceQuery: string;
readonly sourceMap?: boolean;
readonly _compilation?: unknown;
getOptions(): FluentIconsAtomicImportLoaderOptions;
callback(err: Error | null | undefined, content?: string | Buffer, sourceMap?: any, additionalData?: any): void;
emitWarning(warning: Error): void;
Expand Down
Loading