Skip to content
Closed
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
30 changes: 27 additions & 3 deletions packages/react-icons-atomic-webpack-loader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@
> **⚠️ 0.x** — this package is in early development and follows [zero-based major semver](https://0ver.org/).
> Breaking changes may occur in minor releases until 1.0.

Webpack loader that transforms barrel imports and re-exports from `@fluentui/react-icons` into atomic deep paths for better tree-shaking and smaller bundles.
Webpack loader that transforms barrel imports and re-exports from `@fluentui/react-icons` and `@fluentui/react-brand-icons` into atomic deep paths for better tree-shaking and smaller bundles.

## Before / After

```js
// Before — barrel import pulls in the entire icon set
import { AddFilled, bundleIcon, useIconContext } from '@fluentui/react-icons';
import { WordColor } from '@fluentui/react-brand-icons';
export { ArrowLeftRegular } from '@fluentui/react-icons';

// After — each reference resolves to a small, isolated module
import { AddFilled } from '@fluentui/react-icons/svg/add';
import { bundleIcon } from '@fluentui/react-icons/utils';
import { useIconContext } from '@fluentui/react-icons/providers';
import { WordColor } from '@fluentui/react-brand-icons/svg/word';
export { ArrowLeftRegular } from '@fluentui/react-icons/svg/arrow-left';
```

Expand Down Expand Up @@ -64,6 +66,7 @@ module.exports = {
| Option | Type | Default | Description |
| ------------- | -------------------------------------- | ------- | ------------------------------------------------------------------ |
| `iconVariant` | `'svg'` \| `'fonts'` \| `'svg-sprite'` | `'svg'` | Whether icons resolve to SVG, font-based, or SVG sprite components |
| `modules` | `string[]` | `['@fluentui/react-icons', '@fluentui/react-brand-icons']` | Icon package names to transform |

### Using font icons

Expand Down Expand Up @@ -103,19 +106,40 @@ This changes icon resolution from `@fluentui/react-icons/svg/*` to `@fluentui/re

This changes icon resolution from `@fluentui/react-icons/svg/*` to `@fluentui/react-icons/svg-sprite/*`. Non-icon exports (`utils`, `providers`) are unaffected.

### Limiting to specific packages

By default the loader transforms both `@fluentui/react-icons` and `@fluentui/react-brand-icons`. Use the `modules` option to limit which packages are transformed:

```js
{
test: /\.[mc]?[jt]sx?$/,
enforce: 'pre',
use: [
{
loader: '@fluentui/react-icons-atomic-webpack-loader',
options: {
// Only transform react-icons, leave react-brand-icons barrel imports as-is
modules: ['@fluentui/react-icons'],
},
},
],
}
```

## How it works

The loader uses a Babel transform to rewrite import and re-export declarations that reference `@fluentui/react-icons`. Each named specifier is routed to an atomic subpath based on its name:
The loader uses a Babel transform to rewrite import and re-export declarations that reference supported icon packages (`@fluentui/react-icons` and `@fluentui/react-brand-icons` by default). Each named specifier is routed to an atomic subpath based on its name:

| Export type | Example | Resolved path |
| -------------- | ------------------------------------------------ | -------------------------------------------------------------------- |
| Icon component | `AddFilled`, `ArrowLeftRegular` | `@fluentui/react-icons/svg/add` (or `/fonts/add`, `/svg-sprite/add`) |
| Context / hook | `useIconContext`, `IconDirectionContextProvider` | `@fluentui/react-icons/providers` |
| Utility | `bundleIcon`, `createFluentIcon` | `@fluentui/react-icons/utils` |

Files that don't reference `@fluentui/react-icons` are passed through untouched (fast regex pre-check).
Files that don't reference any of the configured icon packages are passed through untouched (fast string pre-check).

## Requirements

- `webpack` >= 5
- `@fluentui/react-icons` >= 2 (with atomic subpath exports)
- `@fluentui/react-brand-icons` >= 2 (with atomic subpath exports, optional)
11 changes: 7 additions & 4 deletions packages/react-icons-atomic-webpack-loader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,25 @@ import type { LoaderContext } from 'webpack';

export interface FluentIconsAtomicImportLoaderOptions {
iconVariant?: 'svg' | 'fonts' | 'svg-sprite';
/** Icon package names to transform. Defaults to `['@fluentui/react-icons', '@fluentui/react-brand-icons']`. */
modules?: string[];
}

export default function fluentIconsAtomicImportLoader(
this: LoaderContext<FluentIconsAtomicImportLoaderOptions>,
sourceCode: string,
): void {
const { resourcePath } = this;
const { iconVariant = 'svg', modules } = this.getOptions();

if (!sourceCode.includes('@fluentui/react-icons')) {
const activeModules = modules ?? ['@fluentui/react-icons', '@fluentui/react-brand-icons'];

if (!activeModules.some((m) => sourceCode.includes(m))) {
return this.callback(null, sourceCode);
}

const { iconVariant = 'svg' } = this.getOptions();

try {
const { code, map } = transformSource(sourceCode, { iconVariant, path: resourcePath });
const { code, map } = transformSource(sourceCode, { iconVariant, path: resourcePath, modules: activeModules });
return this.callback(null, code, map);
} catch {
return this.callback(new Error(`FluentIconsAtomicImportLoader: Failed to transform "${resourcePath}"`));
Expand Down
27 changes: 16 additions & 11 deletions packages/react-icons-atomic-webpack-loader/src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,31 @@ import { parseSync } from 'oxc-parser';
import type { StaticImport, StaticExport } from 'oxc-parser';
import MagicString from 'magic-string';

const MODULE_NAME = '@fluentui/react-icons';
const DEFAULT_MODULES = ['@fluentui/react-icons', '@fluentui/react-brand-icons'];
const ICON_SUFFIX_REGEX = /(\d*)?(Regular|Filled|Light|Color)$/;

interface TransformOptions {
iconVariant: 'svg' | 'fonts' | 'svg-sprite';
path: string;
/** Icon package names to transform. Defaults to react-icons and react-brand-icons. */
modules?: string[];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is bad design, product icons don't and wont support all iconVariants, so this api needs either better more generic design or handle the logic properly internally

}

function getAtomicImportPath(importName: string, iconVariant: 'svg' | 'fonts' | 'svg-sprite'): string {
function getAtomicImportPath(importName: string, iconVariant: 'svg' | 'fonts' | 'svg-sprite', moduleName: string): string {
if (importName === 'useIconContext' || importName === 'IconDirectionContextProvider') {
return '@fluentui/react-icons/providers';
return `${moduleName}/providers`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is wrong, product icons has no provider, no utils export map

Image

}

const isIcon = importName.match(ICON_SUFFIX_REGEX);

if (!isIcon) {
return '@fluentui/react-icons/utils';
return `${moduleName}/utils`;
}

const withoutSuffix = importName.replace(ICON_SUFFIX_REGEX, '');
const kebabCase = withoutSuffix.replace(/[a-z\d](?=[A-Z])|[a-zA-Z](?=\d)|[A-Z](?=[A-Z][a-z])/g, '$&-').toLowerCase();

return `@fluentui/react-icons/${iconVariant}/${kebabCase}`;
return `${moduleName}/${iconVariant}/${kebabCase}`;
}

export interface TransformResult {
Expand All @@ -33,7 +35,8 @@ export interface TransformResult {
}

export function transformSource(source: string, options: TransformOptions): TransformResult {
const { iconVariant, path } = options;
const { iconVariant, path, modules = DEFAULT_MODULES } = options;
const activeModules = new Set(modules);

const result = parseSync(path, source, {
sourceType: 'module',
Expand All @@ -47,7 +50,8 @@ export function transformSource(source: string, options: TransformOptions): Tran
const src = new MagicString(source);

for (const imp of staticImports) {
if (imp.moduleRequest.value !== MODULE_NAME) continue;
const moduleName = imp.moduleRequest.value;
if (!activeModules.has(moduleName)) continue;

const namedEntries = imp.entries.filter((e) => e.importName.kind === 'Name');
if (namedEntries.length === 0) continue;
Expand All @@ -59,13 +63,13 @@ export function transformSource(source: string, options: TransformOptions): Tran
const names = otherEntries
.map((e) => (e.importName.kind === 'Default' ? e.localName.value : `* as ${e.localName.value}`))
.join(', ');
lines.push(`import ${names} from '${MODULE_NAME}';`);
lines.push(`import ${names} from '${moduleName}';`);
}

for (const entry of namedEntries) {
const importedName = entry.importName.name!;
const localName = entry.localName.value;
const newSource = getAtomicImportPath(importedName, iconVariant);
const newSource = getAtomicImportPath(importedName, iconVariant, moduleName);
const spec = importedName === localName ? importedName : `${importedName} as ${localName}`;
lines.push(`import { ${spec} } from '${newSource}';`);
}
Expand All @@ -75,7 +79,7 @@ export function transformSource(source: string, options: TransformOptions): Tran

for (const exp of staticExports) {
const relevantEntries = exp.entries.filter(
(e) => e.moduleRequest?.value === MODULE_NAME && e.exportName.kind === 'Name',
(e) => e.moduleRequest?.value != null && activeModules.has(e.moduleRequest.value) && e.exportName.kind === 'Name',
);
if (relevantEntries.length === 0) continue;

Expand All @@ -87,9 +91,10 @@ export function transformSource(source: string, options: TransformOptions): Tran
const lines: string[] = [];

for (const entry of relevantEntries) {
const moduleName = entry.moduleRequest!.value;
const importedName = entry.importName.name!;
const exportedName = entry.exportName.name!;
const newSource = getAtomicImportPath(importedName, iconVariant);
const newSource = getAtomicImportPath(importedName, iconVariant, moduleName);
const spec = importedName === exportedName ? importedName : `${importedName} as ${exportedName}`;
lines.push(`export { ${spec} } from '${newSource}';`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { transformSource } from '../src/transform';

const transform = (source: string, iconVariant: 'svg' | 'fonts' | 'svg-sprite' = 'svg') =>
transformSource(source, { iconVariant, path: 'input.js' }).code;
const transform = (source: string, iconVariant: 'svg' | 'fonts' | 'svg-sprite' = 'svg', modules?: string[]) =>
transformSource(source, { iconVariant, path: 'input.js', modules }).code;

describe('transformSource', () => {
describe('imports', () => {
Expand Down Expand Up @@ -161,4 +161,83 @@ describe('transformSource', () => {
`);
});
});

describe('@fluentui/react-brand-icons', () => {
it('rewrites brand icon imports to atomic paths', () => {
expect(transform(`import { WordColor } from '@fluentui/react-brand-icons';`)).toBe(
`import { WordColor } from '@fluentui/react-brand-icons/svg/word';`,
);
});

it('rewrites brand icon imports with Filled suffix', () => {
expect(transform(`import { AccessFilled } from '@fluentui/react-brand-icons';`)).toBe(
`import { AccessFilled } from '@fluentui/react-brand-icons/svg/access';`,
);
});

it('rewrites brand icon imports with Regular suffix', () => {
expect(transform(`import { AccessRegular } from '@fluentui/react-brand-icons';`)).toBe(
`import { AccessRegular } from '@fluentui/react-brand-icons/svg/access';`,
);
});

it('splits multi-binding brand icon imports', () => {
expect(transform(`import { WordColor, ExcelColor } from '@fluentui/react-brand-icons';`)).toBe(
[
`import { WordColor } from '@fluentui/react-brand-icons/svg/word';`,
`import { ExcelColor } from '@fluentui/react-brand-icons/svg/excel';`,
].join('\n'),
);
});

it('routes brand icon utility imports to /utils', () => {
expect(transform(`import { bundleIcon, createFluentIcon } from '@fluentui/react-brand-icons';`)).toBe(
[
`import { bundleIcon } from '@fluentui/react-brand-icons/utils';`,
`import { createFluentIcon } from '@fluentui/react-brand-icons/utils';`,
].join('\n'),
);
});

it('rewrites brand icon direct re-exports', () => {
expect(transform(`export { WordColor } from '@fluentui/react-brand-icons';`)).toBe(
`export { WordColor } from '@fluentui/react-brand-icons/svg/word';`,
);
});

it('handles mixed react-icons and react-brand-icons in the same file', () => {
const source = [
`import { AddFilled } from '@fluentui/react-icons';`,
`import { WordColor } from '@fluentui/react-brand-icons';`,
].join('\n');

expect(transform(source)).toBe(
[
`import { AddFilled } from '@fluentui/react-icons/svg/add';`,
`import { WordColor } from '@fluentui/react-brand-icons/svg/word';`,
].join('\n'),
);
});

it('leaves brand icons untouched when excluded via modules option', () => {
const source = `import { WordColor } from '@fluentui/react-brand-icons';`;
expect(transform(source, 'svg', ['@fluentui/react-icons'])).toBe(source);
});
});

describe('modules option', () => {
it('only transforms modules listed in the modules option', () => {
const source = [
`import { AddFilled } from '@fluentui/react-icons';`,
`import { WordColor } from '@fluentui/react-brand-icons';`,
].join('\n');

expect(transform(source, 'svg', ['@fluentui/react-brand-icons'])).toBe(
[
`import { AddFilled } from '@fluentui/react-icons';`,
`import { WordColor } from '@fluentui/react-brand-icons/svg/word';`,
].join('\n'),
);
});
});
});
Loading