diff --git a/packages/docsite/stories/Icons/IconsBuildTransforms.mdx b/packages/docsite/stories/Icons/IconsBuildTransforms.mdx index 55cff112f0a..603f3ed808a 100644 --- a/packages/docsite/stories/Icons/IconsBuildTransforms.mdx +++ b/packages/docsite/stories/Icons/IconsBuildTransforms.mdx @@ -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: diff --git a/packages/react-icons-atomic-webpack-loader/README.md b/packages/react-icons-atomic-webpack-loader/README.md index c9bedd53ecd..2ec30a92742 100644 --- a/packages/react-icons-atomic-webpack-loader/README.md +++ b/packages/react-icons-atomic-webpack-loader/README.md @@ -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: [ + 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` @@ -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: diff --git a/packages/react-icons-atomic-webpack-loader/package.json b/packages/react-icons-atomic-webpack-loader/package.json index 441494d225f..eed0814679e 100644 --- a/packages/react-icons-atomic-webpack-loader/package.json +++ b/packages/react-icons-atomic-webpack-loader/package.json @@ -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" @@ -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" }, diff --git a/packages/react-icons-atomic-webpack-loader/src/direct-path.ts b/packages/react-icons-atomic-webpack-loader/src/direct-path.ts new file mode 100644 index 00000000000..5d341490dc9 --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/src/direct-path.ts @@ -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; +} diff --git a/packages/react-icons-atomic-webpack-loader/src/index.ts b/packages/react-icons-atomic-webpack-loader/src/index.ts index 61d8ffc3ab3..84b0870073d 100644 --- a/packages/react-icons-atomic-webpack-loader/src/index.ts +++ b/packages/react-icons-atomic-webpack-loader/src/index.ts @@ -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 }; @@ -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['map']; @@ -85,6 +132,8 @@ export default function fluentIconsAtomicImportLoader(this: AtomicLoaderContext, fallbackVariant, headless, allowDynamicImports, + moduleGranularity, + sourceMap: generateSourceMap, path: resourcePath, })); } catch (error) { @@ -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}"`, + ); + } } diff --git a/packages/react-icons-atomic-webpack-loader/src/loader-context.ts b/packages/react-icons-atomic-webpack-loader/src/loader-context.ts index c49ea1eed3a..495e0d83c82 100644 --- a/packages/react-icons-atomic-webpack-loader/src/loader-context.ts +++ b/packages/react-icons-atomic-webpack-loader/src/loader-context.ts @@ -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; diff --git a/packages/react-icons-atomic-webpack-loader/src/modules.ts b/packages/react-icons-atomic-webpack-loader/src/modules.ts index a1987644795..8cb3afb9cac 100644 --- a/packages/react-icons-atomic-webpack-loader/src/modules.ts +++ b/packages/react-icons-atomic-webpack-loader/src/modules.ts @@ -1,3 +1,5 @@ +import { getIconFamilyName } from './selector-protocol'; + export type IconVariant = 'svg' | 'fonts' | 'svg-sprite'; /** @@ -9,7 +11,7 @@ export const DEFAULT_SAFETY_VARIANT: IconVariant = 'svg'; const ICON_SUFFIX_REGEX = /(\d*)?(Regular|Filled|Light|Color)$/; -function isIconName(importName: string): boolean { +export function isIconName(importName: string): boolean { return ICON_SUFFIX_REGEX.test(importName); } @@ -29,7 +31,7 @@ function toKebabCase(value: string): string { } function iconBaseName(importName: string): string { - return toKebabCase(importName.replace(ICON_SUFFIX_REGEX, '')); + return getIconFamilyName(importName) ?? toKebabCase(importName.replace(ICON_SUFFIX_REGEX, '')); } /** diff --git a/packages/react-icons-atomic-webpack-loader/src/select-export.ts b/packages/react-icons-atomic-webpack-loader/src/select-export.ts new file mode 100644 index 00000000000..f1bf9988c34 --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/src/select-export.ts @@ -0,0 +1,214 @@ +import { createHash } from 'crypto'; +import { basename } from 'path'; +import { parseSync } from 'oxc-parser'; +import type { Statement } from 'oxc-parser'; +import MagicString from 'magic-string'; + +import { createExportSelector, SELECTOR_CACHE_SALT, type Selector } from './selector-protocol'; + +interface Range { + start: number; + end: number; +} + +interface StructuralModule { + preservedRanges: Range[]; + exports: Map; + dependencies: Map; +} + +const MAX_CACHE_ENTRIES = 256; +const structuralCache = new Map(); +const collectMetrics = process.env.FLUENT_ICON_SELECTOR_METRICS === '1'; +const metrics = { emissions: 0, parses: 0, cacheHits: 0 }; + +export function getSelectorTransformMetrics(): Readonly & { cacheEntries: number } { + return { ...metrics, cacheEntries: structuralCache.size }; +} + +export function resetSelectorTransformMetrics(): void { + structuralCache.clear(); + metrics.emissions = 0; + metrics.parses = 0; + metrics.cacheHits = 0; +} + +export function selectExports( + source: string, + resourcePath: string, + selector: Selector, + generateSourceMap = true, +): { code: string; map: ReturnType | undefined } { + if (collectMetrics) { + metrics.emissions++; + } + if (selector.kind === 'group') { + const filename = basename(resourcePath); + const code = selector.exportNames + .map((exportName) => `export { ${exportName} } from './${filename}${createExportSelector(exportName)}';`) + .join('\n'); + const generated = new MagicString(code); + return { + code, + map: generateSourceMap + ? generated.generateMap({ hires: true, source: resourcePath, includeContent: true }) + : undefined, + }; + } + + const structure = getStructure(source, resourcePath); + const selectedRange = structure.exports.get(selector.exportName); + if (!selectedRange) { + throw new Error(`export "${selector.exportName}" was not found in "${resourcePath}"`); + } + + const dependencies = structure.dependencies.get(selector.exportName) ?? []; + if (dependencies.length > 0) { + throw new Error( + `export "${selector.exportName}" in "${resourcePath}" depends on non-import module binding(s): ` + + dependencies.join(', '), + ); + } + + const src = new MagicString(source); + const retained = [...structure.preservedRanges, selectedRange].sort((a, b) => a.start - b.start); + let cursor = 0; + for (const range of retained) { + if (cursor < range.start) { + src.remove(cursor, range.start); + } + cursor = range.end; + } + if (cursor < source.length) { + src.remove(cursor, source.length); + } + + return { + code: src.toString(), + map: generateSourceMap ? src.generateMap({ hires: true, source: resourcePath, includeContent: true }) : undefined, + }; +} + +function getStructure(source: string, resourcePath: string): StructuralModule { + const hash = createHash('sha256').update(SELECTOR_CACHE_SALT).update('\0').update(source).digest('hex'); + const key = `${resourcePath}\0${hash}`; + const cached = structuralCache.get(key); + if (cached) { + if (collectMetrics) { + metrics.cacheHits++; + } + structuralCache.delete(key); + structuralCache.set(key, cached); + return cached; + } + + const parsed = parseSync(resourcePath, source, { sourceType: 'module' }); + if (collectMetrics) { + metrics.parses++; + } + if (parsed.errors.length > 0) { + throw new Error(parsed.errors[0].message); + } + + const preservedRanges: Range[] = []; + const exports = new Map(); + const moduleBindings = new Set(); + + for (const statement of parsed.program.body) { + if (isDirective(statement) || statement.type === 'ImportDeclaration') { + preservedRanges.push({ start: statement.start, end: statement.end }); + continue; + } + + if (statement.type !== 'ExportNamedDeclaration' || statement.declaration?.type !== 'VariableDeclaration') { + collectTopLevelBindings(statement, moduleBindings); + continue; + } + + if (statement.declaration.declarations.length !== 1) { + throw new Error(`generated export declarations in "${resourcePath}" must declare exactly one binding`); + } + const declaration = statement.declaration.declarations[0]; + if (declaration.id.type !== 'Identifier') { + throw new Error(`generated export declarations in "${resourcePath}" must use identifier bindings`); + } + exports.set(declaration.id.name, { + start: getLeadingCommentStart(source, parsed.comments, statement.start), + end: statement.end, + }); + moduleBindings.add(declaration.id.name); + } + + const references: Array<{ name: string; start: number }> = []; + collectIdentifiers(parsed.program, references); + + const dependencies = new Map(); + for (const [exportName, range] of exports) { + const externalBindings = references + .filter((reference) => reference.start >= range.start && reference.start < range.end) + .map((reference) => reference.name) + .filter((name) => name !== exportName && moduleBindings.has(name)); + dependencies.set(exportName, Array.from(new Set(externalBindings)).sort()); + } + + const structure = { preservedRanges, exports, dependencies }; + structuralCache.set(key, structure); + if (structuralCache.size > MAX_CACHE_ENTRIES) { + structuralCache.delete(structuralCache.keys().next().value!); + } + + function getLeadingCommentStart( + source: string, + comments: Array<{ start: number; end: number }>, + statementStart: number, + ): number { + let start = statementStart; + for (let index = comments.length - 1; index >= 0; index--) { + const comment = comments[index]; + if (comment.end > start) continue; + if (source.slice(comment.end, start).trim() !== '') break; + start = comment.start; + } + return start; + } + + function collectIdentifiers(value: unknown, references: Array<{ name: string; start: number }>): void { + if (!value || typeof value !== 'object') { + return; + } + const node = value as Record; + if (node.type === 'Identifier' && typeof node.name === 'string' && typeof node.start === 'number') { + references.push({ name: node.name, start: node.start }); + } + for (const [key, child] of Object.entries(node)) { + if (key === 'parent') continue; + if (Array.isArray(child)) { + for (const item of child) collectIdentifiers(item, references); + } else { + collectIdentifiers(child, references); + } + } + } + return structure; +} + +function isDirective(statement: Statement): boolean { + return statement.type === 'ExpressionStatement' && typeof statement.directive === 'string'; +} + +function collectTopLevelBindings(statement: Statement, bindings: Set): void { + const declaration = + statement.type === 'ExportNamedDeclaration' && statement.declaration ? statement.declaration : statement; + if (declaration.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if (declarator.id.type === 'Identifier') { + bindings.add(declarator.id.name); + } + } + } else if ( + (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') && + declaration.id + ) { + bindings.add(declaration.id.name); + } +} diff --git a/packages/react-icons-atomic-webpack-loader/src/selector-protocol.ts b/packages/react-icons-atomic-webpack-loader/src/selector-protocol.ts new file mode 100644 index 00000000000..29f465bbd2f --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/src/selector-protocol.ts @@ -0,0 +1,177 @@ +import { basename, extname } from 'path'; + +export const SELECTOR_PROTOCOL_VERSION = 'v1'; +export const SELECTOR_CACHE_SALT = `fluent-icon-selector-${SELECTOR_PROTOCOL_VERSION}`; +export const SELECTOR_QUERY_KEY = '__fluentIcon'; +export const SELECTOR_PROTOCOL_IDENTIFIER = `${SELECTOR_QUERY_KEY}=${SELECTOR_PROTOCOL_VERSION}`; +export const SELECTOR_CAPABILITY = Symbol.for(`fluentui.react-icons.selector-protocol:${SELECTOR_PROTOCOL_IDENTIFIER}`); + +const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const HEX_PATTERN = /^(?:[0-9a-f]{2})+$/; +const SELECTOR_QUERY_PATTERN = new RegExp(`(?:^\\?|&)${SELECTOR_QUERY_KEY}=`); + +export type Selector = { kind: 'export'; exportName: string } | { kind: 'group'; exportNames: string[] }; +export type SelectorCapability = 'fonts' | 'svg-sprite'; + +export function encodeExportName(exportName: string): string { + assertExportIdentifier(exportName); + return Buffer.from(exportName, 'utf8').toString('hex'); +} + +export function decodeExportName(encoded: string): string { + if (!HEX_PATTERN.test(encoded)) { + throw new Error(`selector export "${encoded}" is not canonical lowercase UTF-8 hex`); + } + + const exportName = Buffer.from(encoded, 'hex').toString('utf8'); + if (Buffer.from(exportName, 'utf8').toString('hex') !== encoded) { + throw new Error(`selector export "${encoded}" is not valid canonical UTF-8`); + } + assertExportIdentifier(exportName); + return exportName; +} + +export function createExportSelector(exportName: string): string { + return `?${SELECTOR_QUERY_KEY}=${SELECTOR_PROTOCOL_VERSION}&export=${encodeExportName(exportName)}`; +} + +export function createGroupSelector(exportNames: Iterable): string { + const encoded = Array.from(new Set(Array.from(exportNames, encodeExportName))).sort(); + if (encoded.length === 0) { + throw new Error('a selector group must contain at least one export'); + } + return `?${SELECTOR_QUERY_KEY}=${SELECTOR_PROTOCOL_VERSION}&group=${encoded.join('.')}`; +} + +export function parseSelectorQuery(resourceQuery: string): Selector | null { + if (!resourceQuery) { + return null; + } + if (!SELECTOR_QUERY_PATTERN.test(resourceQuery)) { + return null; + } + if (!resourceQuery.startsWith('?')) { + throw new Error(`malformed Fluent icon selector "${resourceQuery}"`); + } + + // Package export-map wildcard substitution in webpack/rspack can append the + // target's `.js` suffix to the final query value. The importer still emits + // the canonical query; normalize this resolver artifact at the loader edge. + const normalizedQuery = resourceQuery.endsWith('.js') ? resourceQuery.slice(0, -3) : resourceQuery; + const pairs = normalizedQuery.slice(1).split('&'); + const values = new Map(); + for (const pair of pairs) { + const separator = pair.indexOf('='); + if (separator <= 0) { + throw new Error(`malformed Fluent icon selector "${resourceQuery}"`); + } + const key = pair.slice(0, separator); + const value = pair.slice(separator + 1); + if (values.has(key)) { + throw new Error(`duplicate selector key "${key}" in "${resourceQuery}"`); + } + values.set(key, value); + } + + if (!values.has(SELECTOR_QUERY_KEY)) { + return null; + } + for (const key of values.keys()) { + if (key !== SELECTOR_QUERY_KEY && key !== 'export' && key !== 'group') { + throw new Error(`unknown Fluent icon selector key "${key}"`); + } + } + if (values.get(SELECTOR_QUERY_KEY) !== SELECTOR_PROTOCOL_VERSION) { + throw new Error( + `unsupported Fluent icon selector protocol "${values.get(SELECTOR_QUERY_KEY) ?? ''}" ` + + `(expected "${SELECTOR_PROTOCOL_VERSION}")`, + ); + } + + const exportValue = values.get('export'); + const groupValue = values.get('group'); + if ((exportValue === undefined) === (groupValue === undefined)) { + throw new Error('a Fluent icon selector must contain exactly one of "export" or "group"'); + } + + if (exportValue !== undefined) { + const selector: Selector = { kind: 'export', exportName: decodeExportName(exportValue) }; + if (createExportSelector(selector.exportName) !== normalizedQuery) { + throw new Error(`non-canonical Fluent icon selector "${resourceQuery}"`); + } + return selector; + } + + const encodedNames = groupValue!.split('.'); + const exportNames = encodedNames.map(decodeExportName); + const selector: Selector = { kind: 'group', exportNames }; + if (createGroupSelector(exportNames) !== normalizedQuery) { + throw new Error(`non-canonical Fluent icon selector "${resourceQuery}"`); + } + return selector; +} + +export function getPhysicalResource(resource: string): string { + const queryIndex = resource.indexOf('?'); + return queryIndex === -1 ? resource : resource.slice(0, queryIndex); +} + +export function assertSelectableResource(resourcePath: string, selector: Selector): void { + if (extname(resourcePath) === '.cjs') { + throw new Error(`queried CommonJS icon resources are not supported: "${resourcePath}"`); + } + + const normalized = resourcePath.replace(/\\/g, '/'); + if ( + !/\/react-icons\/lib\/atoms\/(?:svg|fonts|headless-svg|headless-fonts|svg-sprite)\/[\w-]+\.js$/.test(normalized) && + !/\/react-brand-icons\/lib\/atoms\/(?:svg|headless-svg)\/[\w-]+\.js$/.test(normalized) + ) { + throw new Error(`selector does not target an allowed generated ESM icon atom: "${resourcePath}"`); + } + + const family = basename(resourcePath, '.js'); + for (const exportName of selector.kind === 'export' ? [selector.exportName] : selector.exportNames) { + if (getIconFamilyName(exportName) !== family) { + throw new Error(`export "${exportName}" does not belong to icon family "${family}" in "${resourcePath}"`); + } + } +} + +export function getSelectorCapability(resourcePath: string): SelectorCapability | null { + const normalized = resourcePath.replace(/\\/g, '/'); + if (/\/atoms\/(?:headless-)?fonts\//.test(normalized)) { + return 'fonts'; + } + if (/\/atoms\/svg-sprite\//.test(normalized)) { + return 'svg-sprite'; + } + return null; +} + +export function getRegisteredSelectorCapabilities(compilation: unknown): Set { + const target = compilation as Record; + let capabilities = target[SELECTOR_CAPABILITY] as Set | undefined; + if (!capabilities) { + capabilities = new Set(); + target[SELECTOR_CAPABILITY] = capabilities; + } + return capabilities; +} + +export function getIconFamilyName(exportName: string): string | null { + const match = /(\d*)?(Regular|Filled|Light|Color)$/.exec(exportName); + if (!match) { + return null; + } + return toKebabCase(exportName.slice(0, match.index)); +} + +function assertExportIdentifier(exportName: string): void { + if (!IDENTIFIER_PATTERN.test(exportName)) { + throw new Error(`selector export "${exportName}" is not a valid JavaScript identifier`); + } +} + +function toKebabCase(value: string): string { + return value.replace(/[a-z\d](?=[A-Z])|[a-zA-Z](?=\d)|[A-Z](?=[A-Z][a-z])/g, '$&-').toLowerCase(); +} diff --git a/packages/react-icons-atomic-webpack-loader/src/source-maps.ts b/packages/react-icons-atomic-webpack-loader/src/source-maps.ts new file mode 100644 index 00000000000..0ff40d75d79 --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/src/source-maps.ts @@ -0,0 +1,44 @@ +import type MagicString from 'magic-string'; + +type GeneratedSourceMap = ReturnType; + +export type SourceMapInput = + | string + | { + version: number; + file?: string | null; + names: string[]; + sourceRoot?: string; + sources: (string | null)[]; + sourcesContent?: (string | null)[]; + mappings: string | unknown[][]; + x_google_ignoreList?: number[]; + }; + +// TS 5.0 cannot parse remapping's `export = function` declaration, so keep the +// compatibility override local until the package ships a standard CJS type declaration. +const remapping = require('@jridgewell/remapping') as ( + input: SourceMapInput | SourceMapInput[], + loader: () => null, +) => SourceMapInput; + +export function composeSourceMaps( + generatedMap: GeneratedSourceMap | undefined, + inputSourceMap: SourceMapInput | undefined, +): GeneratedSourceMap | ReturnType | undefined { + if (!generatedMap || !inputSourceMap) { + return generatedMap; + } + + const generatedMapInput: SourceMapInput = { + version: 3, + file: generatedMap.file, + names: generatedMap.names, + sources: generatedMap.sources, + sourcesContent: generatedMap.sourcesContent, + mappings: generatedMap.mappings, + x_google_ignoreList: generatedMap.x_google_ignoreList, + }; + + return remapping([generatedMapInput, inputSourceMap], () => null); +} diff --git a/packages/react-icons-atomic-webpack-loader/src/transform.ts b/packages/react-icons-atomic-webpack-loader/src/transform.ts index 2a2e015a3a6..0ded2a7d902 100644 --- a/packages/react-icons-atomic-webpack-loader/src/transform.ts +++ b/packages/react-icons-atomic-webpack-loader/src/transform.ts @@ -11,8 +11,11 @@ import { SUPPORTED_MODULE_NAMES, } from './modules'; import type { IconVariant, ModuleDescriptor } from './modules'; +import { isIconName } from './modules'; +import { belongsToDirectIconPath, classifyDirectIconPath } from './direct-path'; +import { createExportSelector, createGroupSelector } from './selector-protocol'; -interface TransformOptions { +export interface TransformOptions { /** The requested icon variant. Applied to every supported module. */ iconVariant: IconVariant; /** The variant to fall back to when a module does not support `iconVariant`. */ @@ -25,6 +28,10 @@ interface TransformOptions { * Defaults to `false`. Un-rewritable dynamic barrel imports still warn. */ allowDynamicImports?: boolean; + /** Emit one logical bundler module per icon export. Defaults to `family`. */ + moduleGranularity?: 'family' | 'icon'; + /** Generate a high-resolution source map. Defaults to `true`. */ + sourceMap?: boolean; path: string; } @@ -35,7 +42,7 @@ export interface Diagnostic { export interface TransformResult { code: string; - map: ReturnType; + map: ReturnType | undefined; /** * Diagnostics gathered while rewriting. Only modules that are actually * imported/re-exported (as reported by the parsed module record) contribute @@ -51,7 +58,15 @@ type ResolvedTarget = { variant: IconVariant; headless: boolean }; type RewriteGroup = { source: string; specs: string[] }; export function transformSource(source: string, options: TransformOptions): TransformResult { - const { iconVariant, fallbackVariant, headless = false, allowDynamicImports = false, path } = options; + const { + iconVariant, + fallbackVariant, + headless = false, + allowDynamicImports = false, + moduleGranularity = 'family', + sourceMap = true, + path, + } = options; const result = parseSync(path, source, { sourceType: 'module', @@ -82,6 +97,9 @@ export function transformSource(source: string, options: TransformOptions): Tran // many icons a file imports. const resolvedTargets = new Map(); + const selectorFor = (importedName: string): string => + moduleGranularity === 'icon' && isIconName(importedName) ? createExportSelector(importedName) : ''; + /** * Returns the target (variant + headless) to rewrite a single referenced * import with, or `null` when the module could not be resolved (an error @@ -132,10 +150,57 @@ export function transformSource(source: string, options: TransformOptions): Tran for (const imp of staticImports) { const moduleName = imp.moduleRequest.value; const descriptor = getModuleDescriptor(moduleName); - if (!descriptor) continue; + const directPath = classifyDirectIconPath(moduleName); + if (!descriptor && !directPath) continue; const namedEntries = imp.entries.filter((e) => e.importName.kind === 'Name'); - if (namedEntries.length === 0) continue; + if (namedEntries.length === 0) { + if (directPath && moduleGranularity === 'icon') { + pushDiagnostic({ + level: 'warning', + message: + `namespace/default import from direct icon family "${moduleName}" cannot be selected at export level; ` + + `retaining family-level behavior.`, + }); + } + continue; + } + + if (directPath) { + if (moduleGranularity !== 'icon') continue; + if (imp.entries.some((entry) => entry.importName.kind !== 'Name')) { + pushDiagnostic({ + level: 'warning', + message: + `namespace/default import from direct icon family "${moduleName}" cannot be selected at export level; ` + + `retaining family-level behavior.`, + }); + continue; + } + const lines: string[] = []; + let canRewrite = true; + for (const entry of namedEntries) { + const importedName = entry.importName.name!; + if (!isIconName(importedName) || !belongsToDirectIconPath(importedName, directPath)) { + canRewrite = false; + pushDiagnostic({ + level: 'warning', + message: + `export "${importedName}" cannot be proven to belong to direct icon family "${moduleName}"; ` + + `retaining family-level behavior.`, + }); + break; + } + const localName = entry.localName.value; + const spec = importedName === localName ? importedName : `${importedName} as ${localName}`; + lines.push(`import { ${spec} } from '${moduleName}${createExportSelector(importedName)}';`); + } + if (canRewrite) { + src.overwrite(imp.start, imp.end, lines.join('\n')); + } + continue; + } + if (!descriptor) continue; // Resolve each named specifier independently — color icons may route to a // different variant than their non-color siblings in the same statement. @@ -161,7 +226,7 @@ export function transformSource(source: string, options: TransformOptions): Tran for (const { entry, importedName, target } of resolvedEntries) { const localName = entry.localName.value; - const newSource = descriptor.resolve(importedName, target!.variant, target!.headless); + const newSource = descriptor.resolve(importedName, target!.variant, target!.headless) + selectorFor(importedName); const spec = importedName === localName ? importedName : `${importedName} as ${localName}`; lines.push(`import { ${spec} } from '${newSource}';`); } @@ -171,7 +236,10 @@ export function transformSource(source: string, options: TransformOptions): Tran for (const exp of staticExports) { const relevantEntries = exp.entries.filter( - (e) => e.moduleRequest && getModuleDescriptor(e.moduleRequest.value) && e.exportName.kind === 'Name', + (e) => + e.moduleRequest && + (getModuleDescriptor(e.moduleRequest.value) || classifyDirectIconPath(e.moduleRequest.value)) && + e.exportName.kind === 'Name', ); if (relevantEntries.length === 0) continue; @@ -181,21 +249,38 @@ export function transformSource(source: string, options: TransformOptions): Tran if (source.startsWith('import', exp.start)) continue; const lines: string[] = []; + let retainOriginalDirectExport = false; for (const entry of relevantEntries) { const moduleName = entry.moduleRequest!.value; - const descriptor = getModuleDescriptor(moduleName)!; + const descriptor = getModuleDescriptor(moduleName); + const directPath = classifyDirectIconPath(moduleName); const importedName = entry.importName.name!; - const target = targetFor(descriptor, isColorIconName(importedName)); - if (!target) continue; + if (directPath) { + if (moduleGranularity !== 'icon') continue; + if (!isIconName(importedName) || !belongsToDirectIconPath(importedName, directPath)) { + retainOriginalDirectExport = true; + pushDiagnostic({ + level: 'warning', + message: + `export "${importedName}" cannot be proven to belong to direct icon family "${moduleName}"; ` + + `retaining family-level behavior.`, + }); + continue; + } + } + const target = descriptor ? targetFor(descriptor, isColorIconName(importedName)) : null; + if (descriptor && !target) continue; const exportedName = entry.exportName.name!; - const newSource = descriptor.resolve(importedName, target.variant, target.headless); + const newSource = descriptor + ? descriptor.resolve(importedName, target!.variant, target!.headless) + selectorFor(importedName) + : moduleName + createExportSelector(importedName); const spec = importedName === exportedName ? importedName : `${importedName} as ${exportedName}`; lines.push(`export { ${spec} } from '${newSource}';`); } - if (lines.length === 0) continue; + if (lines.length === 0 || retainOriginalDirectExport) continue; src.overwrite(exp.start, exp.end, lines.join('\n')); } @@ -249,9 +334,14 @@ export function transformSource(source: string, options: TransformOptions): Tran * @example * // `{ AddFilled, ...rest }` → null (rest element → bail) */ - const buildGroups = (objectPattern: ObjectPattern, descriptor: ModuleDescriptor): RewriteGroup[] | null => { + const buildGroups = ( + objectPattern: ObjectPattern, + descriptor: ModuleDescriptor | undefined, + directSource?: string, + ): RewriteGroup[] | null => { const bySource = new Map(); const order: string[] = []; + const directPath = directSource ? classifyDirectIconPath(directSource) : null; for (const prop of objectPattern.properties) { if (prop.type !== 'Property' || prop.computed || prop.kind !== 'init') return null; @@ -259,7 +349,28 @@ export function transformSource(source: string, options: TransformOptions): Tran const importedName: string = prop.key.name; const localName: string = prop.value.name; - const resolvedSource = resolveNameSource(descriptor, importedName); + let resolvedSource: string | null; + if (descriptor) { + resolvedSource = resolveNameSource(descriptor, importedName); + } else if ( + directSource && + directPath && + moduleGranularity === 'icon' && + isIconName(importedName) && + belongsToDirectIconPath(importedName, directPath) + ) { + resolvedSource = directSource; + } else { + if (directSource) { + pushDiagnostic({ + level: 'warning', + message: + `dynamic export "${importedName}" cannot be proven to belong to direct icon family ` + + `"${directSource}"; retaining family-level behavior.`, + }); + } + return null; + } if (resolvedSource === null) return null; const spec = importedName === localName ? importedName : `${importedName}: ${localName}`; @@ -271,7 +382,17 @@ export function transformSource(source: string, options: TransformOptions): Tran } if (order.length === 0) return null; - return order.map((groupSource) => ({ source: groupSource, specs: bySource.get(groupSource)! })); + return order.map((groupSource) => { + const specs = bySource.get(groupSource)!; + if (moduleGranularity !== 'icon') { + return { source: groupSource, specs }; + } + const exportNames = specs.map((spec) => spec.split(':', 1)[0]); + if (!exportNames.every(isIconName)) { + return { source: groupSource, specs }; + } + return { source: groupSource + createGroupSelector(exportNames), specs }; + }); }; const importCallText = (groups: RewriteGroup[]): string => @@ -298,10 +419,12 @@ export function transformSource(source: string, options: TransformOptions): Tran const importExpr = node.init.argument; if (importExpr.source.type !== 'Literal' || typeof importExpr.source.value !== 'string') return; - const descriptor = getModuleDescriptor(importExpr.source.value); - if (!descriptor) return; + const moduleName = importExpr.source.value; + const descriptor = getModuleDescriptor(moduleName); + const directPath = classifyDirectIconPath(moduleName); + if (!descriptor && !(directPath && moduleGranularity === 'icon')) return; - const groups = buildGroups(node.id, descriptor); + const groups = buildGroups(node.id, descriptor, directPath ? moduleName : undefined); if (!groups) return; src.overwrite(node.start, node.end, `${patternText(groups)} = await ${importCallText(groups)}`); @@ -323,8 +446,10 @@ export function transformSource(source: string, options: TransformOptions): Tran const importExpr = node.callee.object; if (importExpr.source.type !== 'Literal' || typeof importExpr.source.value !== 'string') return; - const descriptor = getModuleDescriptor(importExpr.source.value); - if (!descriptor) return; + const moduleName = importExpr.source.value; + const descriptor = getModuleDescriptor(moduleName); + const directPath = classifyDirectIconPath(moduleName); + if (!descriptor && !(directPath && moduleGranularity === 'icon')) return; const callback = node.arguments[0]; if (!callback || (callback.type !== 'ArrowFunctionExpression' && callback.type !== 'FunctionExpression')) { @@ -334,7 +459,7 @@ export function transformSource(source: string, options: TransformOptions): Tran const param = callback.params[0]; if (param?.type !== 'ObjectPattern') return; - const groups = buildGroups(param, descriptor); + const groups = buildGroups(param, descriptor, directPath ? moduleName : undefined); if (!groups) return; src.overwrite(importExpr.start, importExpr.end, importCallText(groups)); @@ -385,7 +510,7 @@ export function transformSource(source: string, options: TransformOptions): Tran return { code: src.toString(), - map: src.generateMap({ hires: true }), + map: sourceMap ? src.generateMap({ hires: true }) : undefined, diagnostics, }; } diff --git a/packages/react-icons-atomic-webpack-loader/test/benchmark.js b/packages/react-icons-atomic-webpack-loader/test/benchmark.js new file mode 100644 index 00000000000..8dd543197c1 --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/test/benchmark.js @@ -0,0 +1,127 @@ +// @ts-check +process.env.FLUENT_ICON_SELECTOR_METRICS = '1'; + +const { readFileSync, readdirSync } = require('fs'); +const { resolve } = require('path'); +const { performance } = require('perf_hooks'); + +const loader = require('../lib').default; +const { getSelectorTransformMetrics, resetSelectorTransformMetrics, selectExports } = require('../lib/select-export'); + +const SAMPLE_SIZE = Number(process.env.ICON_BENCHMARK_EXPORTS || 5_000); +const ITERATIONS = Number(process.env.ICON_BENCHMARK_ITERATIONS || 20); +const JSON_OUTPUT = process.env.ICON_BENCHMARK_JSON === '1'; +const atomDirectory = resolve(__dirname, '../../react-icons/lib/atoms/svg'); + +/** @type {Array<{ exportName: string; resourcePath: string; source: string }>} */ +const selections = []; +for (const filename of readdirSync(atomDirectory).sort()) { + if (!filename.endsWith('.js')) continue; + const resourcePath = resolve(atomDirectory, filename); + const source = readFileSync(resourcePath, 'utf8'); + const exportPattern = /export const ([A-Za-z_$][\w$]*)\s*=/g; + let match; + while ((match = exportPattern.exec(source)) !== null) { + selections.push({ exportName: match[1], resourcePath, source }); + if (selections.length === SAMPLE_SIZE) break; + } + if (selections.length === SAMPLE_SIZE) break; +} + +if (selections.length < SAMPLE_SIZE) { + throw new Error(`Requested ${SAMPLE_SIZE} exports, but found only ${selections.length}.`); +} + +const importerSource = `import { ${selections.map(({ exportName }) => exportName).join(', ')} } from '@fluentui/react-icons';`; + +/** + * @param {string} source + * @param {'family' | 'icon'} moduleGranularity + * @param {boolean} [sourceMap] + */ +const runLoader = (source, moduleGranularity, sourceMap = false) => { + /** @type {Error | null | undefined} */ + let error; + loader.call( + { + resourcePath: '/app/src/benchmark.js', + resourceQuery: '', + sourceMap, + getOptions: () => ({ iconVariant: 'svg', moduleGranularity }), + emitWarning: () => undefined, + callback: (nextError) => { + error = nextError; + }, + }, + source, + ); + if (error) throw error; +}; + +/** + * @param {string} name + * @param {() => void} run + * @param {number} [iterations] + */ +const benchmark = (name, run, iterations = ITERATIONS) => { + run(); + const rssBefore = process.memoryUsage().rss; + const start = performance.now(); + for (let index = 0; index < iterations; index++) run(); + return { + name, + iterations, + totalMs: Number((performance.now() - start).toFixed(2)), + rssDeltaMiB: Number(((process.memoryUsage().rss - rssBefore) / 1_048_576).toFixed(2)), + }; +}; + +const results = [ + benchmark('fast-skip importer', () => runLoader('export const value = 1;', 'family'), 10_000), + benchmark('family importer rewrite', () => runLoader(importerSource, 'family')), + benchmark('icon importer rewrite', () => runLoader(importerSource, 'icon')), + benchmark('icon importer rewrite with source maps', () => runLoader(importerSource, 'icon', true)), +]; + +resetSelectorTransformMetrics(); +const selectionRssBefore = process.memoryUsage().rss; +const selectionStart = performance.now(); +for (const selection of selections) { + selectExports(selection.source, selection.resourcePath, { kind: 'export', exportName: selection.exportName }, false); +} +const selectionResult = { + name: 'selected export emission', + iterations: selections.length, + totalMs: Number((performance.now() - selectionStart).toFixed(2)), + rssDeltaMiB: Number(((process.memoryUsage().rss - selectionRssBefore) / 1_048_576).toFixed(2)), + ...getSelectorTransformMetrics(), +}; + +const report = { + exports: selections.length, + physicalFamilies: new Set(selections.map(({ resourcePath }) => resourcePath)).size, + defaultSourceMaps: false, + results: [...results, selectionResult], +}; + +if (JSON_OUTPUT) { + console.log(JSON.stringify(report, null, 2)); +} else { + console.log('Fluent icon atomic loader benchmark'); + console.log(`Exports: ${report.exports} across ${report.physicalFamilies} physical families`); + console.log(`Default source maps: ${report.defaultSourceMaps ? 'enabled' : 'disabled'}`); + console.table( + report.results.map(({ name, iterations, totalMs, rssDeltaMiB }) => ({ + Scenario: name, + Iterations: iterations, + 'Total (ms)': totalMs, + 'Avg (ms)': Number((totalMs / iterations).toFixed(4)), + 'RSS change (MiB)': rssDeltaMiB, + })), + ); + console.log( + `Selector cache: ${selectionResult.parses} parses, ${selectionResult.cacheHits} hits, ` + + `${selectionResult.cacheEntries} entries for ${selectionResult.emissions} emissions`, + ); + console.log('Set ICON_BENCHMARK_JSON=1 for machine-readable output.'); +} diff --git a/packages/react-icons-atomic-webpack-loader/test/loader.test.ts b/packages/react-icons-atomic-webpack-loader/test/loader.test.ts new file mode 100644 index 00000000000..bf8f740e4d1 --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/test/loader.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; + +import loader from '../src/index'; +import type { AtomicLoaderContext } from '../src/loader-context'; +import { createExportSelector, SELECTOR_CAPABILITY, SELECTOR_PROTOCOL_IDENTIFIER } from '../src/selector-protocol'; +import type { SourceMapInput } from '../src/source-maps'; + +const source = [ + '"use client";', + "import { createFluentIcon } from '../../utils/createFluentIcon.js';", + "export const AddFilled = createFluentIcon('AddFilled', '1em', ['filled']);", + "export const AddRegular = createFluentIcon('AddRegular', '1em', ['regular']);", +].join('\n'); + +interface RunLoaderOptions { + compilation?: unknown; + inputSource?: string; + inputSourceMap?: SourceMapInput; + sourceMap?: boolean; +} + +function runLoader(resourcePath: string, resourceQuery: string, options: RunLoaderOptions = {}) { + let result: { error?: Error | null; code?: string; map?: unknown } = {}; + const context: AtomicLoaderContext = { + resourcePath, + resourceQuery, + sourceMap: options.sourceMap ?? true, + _compilation: options.compilation ?? {}, + getOptions: () => ({ moduleGranularity: 'icon' }), + emitWarning: () => undefined, + callback: (error, code, map) => { + result = { error, code: typeof code === 'string' ? code : undefined, map }; + }, + }; + loader.call(context, options.inputSource ?? source, options.inputSourceMap); + return result; +} + +describe('loader selector branch', () => { + it('runs before package and source-text bailouts', () => { + const result = runLoader( + '/app/node_modules/@fluentui/react-icons/lib/atoms/svg/add.js', + createExportSelector('AddFilled'), + ); + expect(result.error).toBeNull(); + expect(result.code).toContain('export const AddFilled'); + expect(result.code).not.toContain('export const AddRegular'); + }); + + it('fails closed when font selector capability is missing', () => { + const result = runLoader( + '/app/node_modules/@fluentui/react-icons/lib/atoms/fonts/add.js', + createExportSelector('AddFilled'), + ); + expect(result.error?.message).toContain('requires a query-aware subsetting plugin'); + }); + + it('accepts a plugin registered for the complete selector protocol identifier', () => { + const compilation = { [SELECTOR_CAPABILITY]: new Set(['fonts']) }; + const result = runLoader( + '/app/node_modules/@fluentui/react-icons/lib/atoms/fonts/add.js', + createExportSelector('AddFilled'), + { compilation }, + ); + expect(result.error).toBeNull(); + expect(result.code).toContain('export const AddFilled'); + }); + + it('rejects a plugin registered under a different selector key', () => { + const wrongKeyCapability = Symbol.for('fluentui.react-icons.selector-protocol:_fluentIcon=v1'); + const compilation = { [wrongKeyCapability]: new Set(['fonts']) }; + const result = runLoader( + '/app/node_modules/@fluentui/react-icons/lib/atoms/fonts/add.js', + createExportSelector('AddFilled'), + { compilation }, + ); + expect(result.error?.message).toContain(`selector protocol "${SELECTOR_PROTOCOL_IDENTIFIER}"`); + }); + + it('rejects a plugin registered under a different selector version', () => { + const staleVersionCapability = Symbol.for('fluentui.react-icons.selector-protocol:__fluentIcon=v2'); + const compilation = { [staleVersionCapability]: new Set(['fonts']) }; + const result = runLoader( + '/app/node_modules/@fluentui/react-icons/lib/atoms/fonts/add.js', + createExportSelector('AddFilled'), + { compilation }, + ); + expect(result.error?.message).toContain(`selector protocol "${SELECTOR_PROTOCOL_IDENTIFIER}"`); + }); + + it('rejects queried CommonJS resources', () => { + const result = runLoader( + '/app/node_modules/@fluentui/react-icons/lib-cjs/atoms/svg/add.cjs', + createExportSelector('AddFilled'), + ); + expect(result.error?.message).toContain('queried CommonJS icon resources are not supported'); + }); + + it('rejects a selector whose export belongs to another physical family', () => { + const result = runLoader( + '/app/node_modules/@fluentui/react-icons/lib/atoms/svg/add.js', + createExportSelector('ArrowLeftRegular'), + ); + expect(result.error?.message).toContain('does not belong to icon family "add"'); + }); + + it('passes an incoming map through unchanged on the fast no-op path', () => { + const inputMap: SourceMapInput = { version: 3, sources: ['original.ts'], names: [], mappings: 'AAAA' }; + const result = runLoader('/app/src/plain.js', '', { + inputSource: 'export const value = 1;', + inputSourceMap: inputMap, + }); + expect(result.map).toBe(inputMap); + }); + + it('composes an incoming map for ordinary barrel rewrites', () => { + const inputMap: SourceMapInput = { + version: 3, + file: 'intermediate.js', + sources: ['original.ts'], + sourcesContent: [`import { AddFilled } from '@fluentui/react-icons';`], + names: [], + mappings: 'AAAA', + }; + const result = runLoader('/app/src/icons.js', '', { + inputSource: `import { AddFilled } from '@fluentui/react-icons';`, + inputSourceMap: inputMap, + }); + expect(result.error).toBeNull(); + expect((result.map as { sources: string[] }).sources).toContain('original.ts'); + }); + + it('skips map generation when the bundler disables source maps', () => { + const result = runLoader('/app/src/icons.js', '', { + inputSource: `import { AddFilled } from '@fluentui/react-icons';`, + sourceMap: false, + }); + expect(result.map).toBeUndefined(); + }); + + it('skips selected-module map generation when the bundler disables source maps', () => { + const result = runLoader( + '/app/node_modules/@fluentui/react-icons/lib/atoms/svg/add.js', + createExportSelector('AddFilled'), + { sourceMap: false }, + ); + expect(result.map).toBeUndefined(); + }); + + it.each([ + { + name: 'generated package pass-through', + resourcePath: '/app/node_modules/@fluentui/react-icons/lib/atoms/svg/add.js', + inputSource: source, + }, + { + name: 'source-text fast no-op', + resourcePath: '/app/src/plain.js', + inputSource: 'export const value = 1;', + }, + ])('drops an incoming map on the $name path when source maps are disabled', ({ resourcePath, inputSource }) => { + const inputMap: SourceMapInput = { version: 3, sources: ['original.ts'], names: [], mappings: 'AAAA' }; + const result = runLoader(resourcePath, '', { + inputSource, + inputSourceMap: inputMap, + sourceMap: false, + }); + expect(result.map).toBeUndefined(); + }); +}); diff --git a/packages/react-icons-atomic-webpack-loader/test/make-configs.js b/packages/react-icons-atomic-webpack-loader/test/make-configs.js index 0a6f7b223b5..ffa564b6093 100644 --- a/packages/react-icons-atomic-webpack-loader/test/make-configs.js +++ b/packages/react-icons-atomic-webpack-loader/test/make-configs.js @@ -8,6 +8,7 @@ */ const { resolve } = require('path'); const { readdirSync, readFileSync } = require('fs'); +const { createExportSelector, createGroupSelector } = require('../lib/selector-protocol'); /** * @typedef {object} EntryAssertions @@ -25,6 +26,9 @@ const { readdirSync, readFileSync } = require('fs'); * @property {string[]} mustInclude * @property {string[]} mustExclude * @property {string[]} [mustWarn] + * @property {boolean} [bundleIcons] + * @property {string[]} [selectedExports] + * @property {string[][]} [selectedGroups] * @property {Record} [overrides] Per-bundler assertion overrides, * keyed by bundler name. Only present where a bundler's output legitimately differs. */ @@ -199,6 +203,23 @@ const entries = { mustInclude: ['@fluentui/react-icons/svg/add', '@fluentui/react-icons/svg/arrow-left'], mustExclude: ['"@fluentui/react-icons"'], }, + 'icon-granularity-svg': { + src: './src/icon-granularity-svg.js', + loaderOptions: { moduleGranularity: 'icon' }, + bundleIcons: true, + selectedExports: ['AddFilled', 'AddRegular', 'DrawImage24Filled'], + mustInclude: ['AddFilled', 'AddRegular', 'DrawImage24Filled'], + mustExclude: ['Add12Regular', 'DrawImageRegular'], + }, + 'icon-granularity-dynamic': { + src: './src/icon-granularity-dynamic.js', + loaderOptions: { moduleGranularity: 'icon', allowDynamicImports: true }, + bundleIcons: true, + selectedExports: ['AddFilled', 'AddRegular'], + selectedGroups: [['AddFilled', 'AddRegular']], + mustInclude: ['AddFilled', 'AddRegular'], + mustExclude: ['Add12Regular'], + }, }; /** @@ -239,11 +260,16 @@ function createConfig(name, entry, adapter) { resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'], }, - externals: [/^@fluentui\/react-icons/, /^@fluentui\/react-brand-icons/, /^react$/], + externals: entry.bundleIcons + ? [/^react$/, /^@griffel\//] + : [/^@fluentui\/react-icons/, /^@fluentui\/react-brand-icons/, /^react$/], module: { rules: [ { test: /\.(jsx?|tsx?)$/, + include: entry.bundleIcons + ? [resolve(__dirname, 'src'), /[\\/]react-(?:brand-)?icons[\\/]lib[\\/]atoms[\\/]/] + : undefined, enforce: 'pre', use: [ { @@ -252,6 +278,14 @@ function createConfig(name, entry, adapter) { }, ], }, + ...(entry.bundleIcons + ? [ + { + resourceQuery: /raw/, + type: 'asset/source', + }, + ] + : []), adapter.typescriptRule(__dirname), ], }, @@ -309,6 +343,50 @@ function createAssertionPlugin(name, entry, adapter) { } } + if (entry.selectedExports !== undefined) { + const resources = Array.from(compilation.modules, (m) => { + const module = /** @type {{ resource?: string, identifier?: () => string }} */ (m); + return module.resource ?? module.identifier?.(); + }); + const selectorResources = new Set( + resources.flatMap((resource) => { + if (typeof resource !== 'string') return []; + const match = resource.match(/[^!|]+\.js\?__fluentIcon=v1&(?:export|group)=[^!|]+/); + return match ? [match[0]] : []; + }), + ); + const selectedResources = Array.from(selectorResources).filter((resource) => resource.includes('&export=')); + const getCanonicalQuery = (resource) => { + const query = resource.slice(resource.indexOf('?')); + return query.endsWith('.js') ? query.slice(0, -3) : query; + }; + const selectedQueries = selectedResources.map(getCanonicalQuery).sort(); + const expectedSelectedQueries = entry.selectedExports.map(createExportSelector).sort(); + if (JSON.stringify(selectedQueries) !== JSON.stringify(expectedSelectedQueries)) { + throw new Error( + `[${label}] Expected selected icon queries ${JSON.stringify(expectedSelectedQueries)}, found ` + + `${JSON.stringify(selectedQueries)}`, + ); + } + const selectedGroups = Array.from(selectorResources).filter((resource) => resource.includes('&group=')); + const selectedGroupQueries = selectedGroups.map(getCanonicalQuery).sort(); + const expectedGroupQueries = (entry.selectedGroups ?? []).map(createGroupSelector).sort(); + if (JSON.stringify(selectedGroupQueries) !== JSON.stringify(expectedGroupQueries)) { + throw new Error( + `[${label}] Expected selector group queries ${JSON.stringify(expectedGroupQueries)}, found ` + + `${JSON.stringify(selectedGroupQueries)}`, + ); + } + const unselectedAtom = resources.find( + (resource) => + typeof resource === 'string' && + /[\\/]react-icons[\\/]lib[\\/]atoms[\\/]svg[\\/](add|draw-image)\.js$/.test(resource), + ); + if (unselectedAtom) { + throw new Error(`[${label}] Found an unselected family module in icon mode: ${unselectedAtom}`); + } + } + console.log(` ✓ ${label}: all assertions passed`); }); }, diff --git a/packages/react-icons-atomic-webpack-loader/test/selector-protocol.test.ts b/packages/react-icons-atomic-webpack-loader/test/selector-protocol.test.ts new file mode 100644 index 00000000000..624d699fe2b --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/test/selector-protocol.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertSelectableResource, + createExportSelector, + createGroupSelector, + decodeExportName, + parseSelectorQuery, +} from '../src/selector-protocol'; +import { selectExports } from '../src/select-export'; + +describe('selector protocol', () => { + it('serializes export names as canonical lowercase UTF-8 hex', () => { + const query = createExportSelector('Prompt16Regular'); + expect(query).toBe('?__fluentIcon=v1&export=50726f6d70743136526567756c6172'); + expect(parseSelectorQuery(query)).toEqual({ kind: 'export', exportName: 'Prompt16Regular' }); + }); + + it('sorts and deduplicates group selectors', () => { + const query = createGroupSelector(['AddRegular', 'AddFilled', 'AddRegular']); + expect(query).toBe('?__fluentIcon=v1&group=41646446696c6c6564.416464526567756c6172'); + expect(parseSelectorQuery(query)).toEqual({ + kind: 'group', + exportNames: ['AddFilled', 'AddRegular'], + }); + }); + + it('accepts the export-map target suffix added by webpack and rspack', () => { + expect(parseSelectorQuery(`${createExportSelector('AddFilled')}.js`)).toEqual({ + kind: 'export', + exportName: 'AddFilled', + }); + }); + + it.each([ + '?__fluentIcon=v2&export=41646446696c6c6564', + '?__fluentIcon=v1&export=ADD', + '?__fluentIcon=v1&export=41646446696c6c6564&extra=1', + '?__fluentIcon=v1&export=41646446696c6c6564&export=416464526567756c6172', + '?__fluentIcon=v1&group=416464526567756c6172.41646446696c6c6564', + ])('rejects malformed or non-canonical selector %s', (query) => { + expect(() => parseSelectorQuery(query)).toThrow(); + }); + + it('ignores unrelated resource queries', () => { + expect(parseSelectorQuery('?raw')).toBeNull(); + }); + + it('rejects invalid decoded identifiers', () => { + expect(() => decodeExportName(Buffer.from('not-an-identifier!', 'utf8').toString('hex'))).toThrow( + 'valid JavaScript identifier', + ); + }); +}); + +describe('selected ESM emission', () => { + const source = [ + '"use client";', + "import { createFluentIcon } from '../../utils/createFluentIcon.js';", + "export const AddFilled = createFluentIcon('AddFilled', '1em', ['filled']);", + "export const AddRegular = createFluentIcon('AddRegular', '1em', ['regular']);", + ].join('\n'); + + const selectExport = (resourcePath: string, moduleSource: string, exportName: string) => { + const selector = { kind: 'export', exportName } as const; + assertSelectableResource(resourcePath, selector); + return selectExports(moduleSource, resourcePath, selector).code; + }; + + it('preserves directives and imports for a selected system icon declaration', () => { + expect( + selectExport('/app/node_modules/@fluentui/react-icons/lib/atoms/svg/add.js', source, 'AddFilled'), + ).toMatchInlineSnapshot( + `""use client";import { createFluentIcon } from '../../utils/createFluentIcon.js';export const AddFilled = createFluentIcon('AddFilled', '1em', ['filled']);"`, + ); + }); + + it('preserves directives and imports for a selected brand icon declaration', () => { + const brandSource = [ + '"use client";', + "import { createFluentIcon } from '../../utils/createFluentIcon.js';", + "export const ProjectColor = createFluentIcon('ProjectColor', '1em', ['color']);", + "export const ProjectRegular = createFluentIcon('ProjectRegular', '1em', ['regular']);", + ].join('\n'); + expect( + selectExport( + '/app/node_modules/@fluentui/react-brand-icons/lib/atoms/svg/project.js', + brandSource, + 'ProjectColor', + ), + ).toMatchInlineSnapshot( + `""use client";import { createFluentIcon } from '../../utils/createFluentIcon.js';export const ProjectColor = createFluentIcon('ProjectColor', '1em', ['color']);"`, + ); + }); + + it('emits groups as re-exports from canonical per-icon selector modules', () => { + const resourcePath = '/app/node_modules/@fluentui/react-icons/lib/atoms/svg/add.js'; + const result = selectExports('', resourcePath, { + kind: 'group', + exportNames: ['AddFilled', 'AddRegular'], + }); + expect(result.code).toMatchInlineSnapshot(` + "export { AddFilled } from './add.js?__fluentIcon=v1&export=41646446696c6c6564'; + export { AddRegular } from './add.js?__fluentIcon=v1&export=416464526567756c6172';" + `); + }); + + it('rejects declarations that reference another module-level binding', () => { + const resourcePath = '/app/node_modules/@fluentui/react-icons/lib/atoms/svg/add.js'; + const source = [ + "import { createFluentIcon } from '../../utils/createFluentIcon.js';", + "const shared = ['path'];", + "export const AddFilled = createFluentIcon('AddFilled', '1em', shared);", + ].join('\n'); + + expect(() => selectExports(source, resourcePath, { kind: 'export', exportName: 'AddFilled' })).toThrow( + 'depends on non-import module binding(s): shared', + ); + }); +}); diff --git a/packages/react-icons-atomic-webpack-loader/test/src/icon-granularity-dynamic.js b/packages/react-icons-atomic-webpack-loader/test/src/icon-granularity-dynamic.js new file mode 100644 index 00000000000..d9f37e435cb --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/test/src/icon-granularity-dynamic.js @@ -0,0 +1,4 @@ +export const loadIcons = async () => { + const { AddFilled, AddRegular } = await import('@fluentui/react-icons'); + return { AddFilled, AddRegular }; +}; diff --git a/packages/react-icons-atomic-webpack-loader/test/src/icon-granularity-svg.js b/packages/react-icons-atomic-webpack-loader/test/src/icon-granularity-svg.js new file mode 100644 index 00000000000..9a5768c9060 --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/test/src/icon-granularity-svg.js @@ -0,0 +1,3 @@ +import { AddFilled, AddRegular, DrawImage24Filled } from '@fluentui/react-icons'; + +export { AddFilled, AddRegular, DrawImage24Filled }; diff --git a/packages/react-icons-atomic-webpack-loader/test/transform.test.ts b/packages/react-icons-atomic-webpack-loader/test/transform.test.ts index 6ef04821be6..5ebb38d7948 100644 --- a/packages/react-icons-atomic-webpack-loader/test/transform.test.ts +++ b/packages/react-icons-atomic-webpack-loader/test/transform.test.ts @@ -6,6 +6,91 @@ const transform = (source: string, iconVariant: IconVariant = 'svg', fallbackVar transformSource(source, { iconVariant, fallbackVariant, path: 'input.js' }).code; describe('transformSource', () => { + describe('icon module granularity', () => { + const transformIcons = (source: string, extra: Partial[1]> = {}) => + transformSource(source, { + iconVariant: 'svg', + moduleGranularity: 'icon', + path: 'input.js', + ...extra, + }); + + it('adds deterministic selectors to icons but not utilities or providers', () => { + const { code } = transformIcons(`import { AddFilled, bundleIcon, useIconContext } from '@fluentui/react-icons';`); + expect(code).toBe( + [ + `import { AddFilled } from '@fluentui/react-icons/svg/add?__fluentIcon=v1&export=41646446696c6c6564';`, + `import { bundleIcon } from '@fluentui/react-icons/utils';`, + `import { useIconContext } from '@fluentui/react-icons/providers';`, + ].join('\n'), + ); + }); + + it('preserves explicit direct-path strategy while selecting the export', () => { + const { code } = transformIcons(`import { AddFilled } from '@fluentui/react-icons/svg/add';`, { + iconVariant: 'fonts', + headless: true, + }); + + expect(code).toBe( + `import { AddFilled } from '@fluentui/react-icons/svg/add?__fluentIcon=v1&export=41646446696c6c6564';`, + ); + }); + + it('selects the configured headless font variant', () => { + const { code } = transformIcons(`import { AddFilled } from '@fluentui/react-icons';`, { + iconVariant: 'fonts', + headless: true, + }); + expect(code).toBe( + `import { AddFilled } from '@fluentui/react-icons/headless/fonts/add?__fluentIcon=v1&export=41646446696c6c6564';`, + ); + }); + + it('adds selectors to direct re-exports without changing their strategy', () => { + const { code } = transformIcons(`export { AddFilled as Plus } from '@fluentui/react-icons/svg/add';`, { + iconVariant: 'fonts', + headless: true, + }); + expect(code).toBe( + `export { AddFilled as Plus } from '@fluentui/react-icons/svg/add?__fluentIcon=v1&export=41646446696c6c6564';`, + ); + }); + + it('warns and retains a mismatched direct family import', () => { + const source = `import { ArrowLeftRegular } from '@fluentui/react-icons/svg/add';`; + const { code, diagnostics } = transformIcons(source); + expect(code).toBe(source); + expect(diagnostics[0].message).toContain('cannot be proven to belong'); + }); + + it('warns and retains direct namespace imports', () => { + const source = `import * as AddIcons from '@fluentui/react-icons/svg/add';`; + const { code, diagnostics } = transformIcons(source); + expect(code).toBe(source); + expect(diagnostics[0].message).toContain('namespace/default import'); + }); + + it('uses one deterministic group request for same-family dynamic exports', () => { + const { code } = transformIcons(`const { AddRegular, AddFilled } = await import('@fluentui/react-icons');`, { + allowDynamicImports: true, + }); + expect(code).toBe( + `const { AddRegular, AddFilled } = await import('@fluentui/react-icons/svg/add?__fluentIcon=v1&group=41646446696c6c6564.416464526567756c6172');`, + ); + }); + + it('selects supported dynamic direct-family imports without rerouting them', () => { + const { code } = transformIcons( + `const { AddRegular, AddFilled } = await import('@fluentui/react-icons/svg/add');`, + { allowDynamicImports: true, iconVariant: 'fonts', headless: true }, + ); + expect(code).toBe( + `const { AddRegular, AddFilled } = await import('@fluentui/react-icons/svg/add?__fluentIcon=v1&group=41646446696c6c6564.416464526567756c6172');`, + ); + }); + }); + describe('imports', () => { it('rewrites a single named icon import to its atomic path', () => { expect(transform(`import { AddFilled } from '@fluentui/react-icons';`)).toBe( diff --git a/packages/react-icons-font-subsetting-webpack-plugin/README.md b/packages/react-icons-font-subsetting-webpack-plugin/README.md index 34bde4554d1..25486658173 100644 --- a/packages/react-icons-font-subsetting-webpack-plugin/README.md +++ b/packages/react-icons-font-subsetting-webpack-plugin/README.md @@ -39,6 +39,13 @@ The plugin subsets the same shared font files used by the standard API, based on > **Tip 💡:** You don't have to write atomic headless imports by hand. Pair this plugin with [`@fluentui/react-icons-atomic-webpack-loader`](../react-icons-atomic-webpack-loader) using `{ headless: true, iconVariant: 'fonts' }` — it rewrites plain barrel imports (`import { AddFilled } from '@fluentui/react-icons'`) into headless font atoms, which this plugin then subsets. You still import `@fluentui/react-icons/headless/fonts/styles.css` yourself. +The plugin is query-aware for the atomic loader's +`moduleGranularity: 'icon'` mode. Queried modules are attributed through their +query-free physical paths while their exact selected exports are aggregated. +Use coordinated loader/plugin releases: the two packages perform a +compilation-level `v1` selector capability handshake and fail the build instead +of producing an incorrect subset when support is missing or incompatible. + ## Usage ### With `fluentIconFont` condition diff --git a/packages/react-icons-font-subsetting-webpack-plugin/src/index.ts b/packages/react-icons-font-subsetting-webpack-plugin/src/index.ts index 5b2ef5dee7d..786a25fa019 100644 --- a/packages/react-icons-font-subsetting-webpack-plugin/src/index.ts +++ b/packages/react-icons-font-subsetting-webpack-plugin/src/index.ts @@ -11,6 +11,7 @@ import type { BundlerPlugin, BundlerRawSource, } from './bundler-api'; +import { assertSupportedSelector, registerSelectorCapability } from './selector-protocol'; export type * from './bundler-api'; @@ -88,6 +89,7 @@ export default class FluentUIReactIconsFontSubsettingPlugin implements BundlerPl const { Compilation, sources } = compiler.webpack; compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + registerSelectorCapability(compilation); compilation.hooks.processAssets.tapPromise( { name: PLUGIN_NAME, stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE }, async () => { @@ -96,12 +98,13 @@ export default class FluentUIReactIconsFontSubsettingPlugin implements BundlerPl for (const m of compilation.modules) { if (isFluentUIReactFontChunk(m)) { + assertSupportedSelector(m.resource, compilation); const icons = resolveUsedIconExports(m, compilation.moduleGraph, runtime); if (icons === null) { continue; } - const outputRoot = resolve(dirname(m.resource), '../..'); + const outputRoot = resolve(dirname(getPhysicalResource(m.resource)), '../..'); const packageRoot = dirname(outputRoot); const usage = packageUsages.get(packageRoot) ?? { outputRoots: new Set(), @@ -359,7 +362,7 @@ function isFluentUIReactFontChunk(m: BundlerModule): m is BundlerNormalModule { return false; } - const resource = m.resource; + const resource = getPhysicalResource(m.resource); if (!resource) { return false; } @@ -372,6 +375,11 @@ function isFluentUIReactFontChunk(m: BundlerModule): m is BundlerNormalModule { return REACT_ICONS_FONT_MODULE_IMPORT_PATTERN.test(resource); } +function getPhysicalResource(resource: string): string { + const queryIndex = resource.indexOf('?'); + return queryIndex === -1 ? resource : resource.slice(0, queryIndex); +} + /** * Maps emitted font assets back to their codepoint tables. * diff --git a/packages/react-icons-font-subsetting-webpack-plugin/src/selector-protocol.ts b/packages/react-icons-font-subsetting-webpack-plugin/src/selector-protocol.ts new file mode 100644 index 00000000000..0602b785caf --- /dev/null +++ b/packages/react-icons-font-subsetting-webpack-plugin/src/selector-protocol.ts @@ -0,0 +1,30 @@ +import type { BundlerCompilation } from './bundler-api'; + +const PLUGIN_NAME = 'FluentUIReactIconsFontSubsettingPlugin'; +const SELECTOR_QUERY_KEY = '__fluentIcon'; +const SELECTOR_PROTOCOL_VERSION = 'v1'; +const SELECTOR_PROTOCOL_IDENTIFIER = `${SELECTOR_QUERY_KEY}=${SELECTOR_PROTOCOL_VERSION}`; +const SELECTOR_CAPABILITY = Symbol.for(`fluentui.react-icons.selector-protocol:${SELECTOR_PROTOCOL_IDENTIFIER}`); +const SELECTOR_PROTOCOL_PATTERN = new RegExp(`(?:\\?|&)${SELECTOR_QUERY_KEY}=([^&]+)`); + +export function assertSupportedSelector(resource: string, compilation: BundlerCompilation): void { + const match = SELECTOR_PROTOCOL_PATTERN.exec(resource); + if (match && match[1] !== SELECTOR_PROTOCOL_VERSION) { + compilation.errors.push( + new Error( + `${PLUGIN_NAME}: unsupported Fluent icon selector protocol "${match[1]}" ` + + `(expected "${SELECTOR_PROTOCOL_VERSION}") in "${resource}".`, + ), + ); + } +} + +export function registerSelectorCapability(compilation: BundlerCompilation): void { + const target = compilation as unknown as Record; + let capabilities = target[SELECTOR_CAPABILITY] as Set | undefined; + if (!capabilities) { + capabilities = new Set(); + target[SELECTOR_CAPABILITY] = capabilities; + } + capabilities.add('fonts'); +} diff --git a/packages/react-icons-font-subsetting-webpack-plugin/test/make-configs.js b/packages/react-icons-font-subsetting-webpack-plugin/test/make-configs.js index f6cd9d3a78e..42166e84f8a 100644 --- a/packages/react-icons-font-subsetting-webpack-plugin/test/make-configs.js +++ b/packages/react-icons-font-subsetting-webpack-plugin/test/make-configs.js @@ -35,6 +35,13 @@ const entries = { useAtomicLoader: true, assertNoGriffel: true, }, + e2eBarrelHeadlessFontsIcon: { + src: './src/e2e-barrel-headless-fonts.js', + threshold: 1.5 * 1_024, // 1.5 KB + useAtomicLoader: true, + moduleGranularity: 'icon', + assertNoGriffel: true, + }, // Regression guard: naming the runtime chunk makes the runtime name differ from the entry name. // rspack resolves used exports per runtime, so querying the wrong one reports every module as // unused and silently subsets nothing. Every other entry here happens to have runtime === entry @@ -51,6 +58,7 @@ const entries = { * @property {string} src * @property {number} threshold * @property {boolean} [useAtomicLoader] + * @property {'icon'} [moduleGranularity] * @property {boolean} [assertNoGriffel] * @property {boolean} [assertModuleFormats] * @property {string} [runtimeChunkName] Name the runtime chunk, decoupling runtime name from entry name. @@ -107,12 +115,16 @@ function createConfig(name, entry, adapter, isDevServer) { // Rewrite barrel `@fluentui/react-icons` imports to headless font atoms // before the bundler parses them. test: /\.js$/, - include: resolve(__dirname, 'src'), + include: [resolve(__dirname, 'src'), /[\\/]react-(?:brand-)?icons[\\/]lib[\\/]atoms[\\/]/], enforce: /** @type {'pre'} */ ('pre'), use: [ { loader: resolve(__dirname, '../../react-icons-atomic-webpack-loader/lib/index.js'), - options: { headless: true, iconVariant: 'fonts' }, + options: { + headless: true, + iconVariant: 'fonts', + ...(entry.moduleGranularity ? { moduleGranularity: entry.moduleGranularity } : {}), + }, }, ], }, diff --git a/packages/react-icons-font-subsetting-webpack-plugin/test/plugin.test.ts b/packages/react-icons-font-subsetting-webpack-plugin/test/plugin.test.ts index cfc2c0701fa..be8b28625fd 100644 --- a/packages/react-icons-font-subsetting-webpack-plugin/test/plugin.test.ts +++ b/packages/react-icons-font-subsetting-webpack-plugin/test/plugin.test.ts @@ -159,6 +159,26 @@ async function harness(options: HarnessOptions) { } describe('runtime resolution', () => { + it('normalizes selector queries before package-root path arithmetic', async () => { + const { updatedAssets, errors } = await harness({ + moduleResources: [`${FONT_MODULE}?__fluentIcon=v1&export=47616d657346696c6c6564`], + usedExports: () => ['GamesFilled'], + }); + + expect(errors).toEqual([]); + expect(updatedAssets).toHaveLength(1); + }); + + it('hard-errors on an unsupported selector protocol', async () => { + const { errors } = await harness({ + moduleResources: [`${FONT_MODULE}?__fluentIcon=v2&export=47616d657346696c6c6564`], + usedExports: () => ['GamesFilled'], + }); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('unsupported Fluent icon selector protocol "v2"'); + }); + it('asks rspack about the runtime chunk, not the entrypoint', async () => { const { runtimesSeen } = await harness({ isRspack: true, diff --git a/packages/react-icons-svg-sprite-subsetting-webpack-plugin/README.md b/packages/react-icons-svg-sprite-subsetting-webpack-plugin/README.md index 1fcf5891154..fd3182c6209 100644 --- a/packages/react-icons-svg-sprite-subsetting-webpack-plugin/README.md +++ b/packages/react-icons-svg-sprite-subsetting-webpack-plugin/README.md @@ -52,6 +52,14 @@ You can pass a hash of configuration options to the plugin. Allowed values are a - For best results, run Webpack in production mode (or enable `optimization.usedExports`) so the plugin can detect which icon exports are used. - `injectSpritesInTemplates` requires `html-webpack-plugin` to be installed and configured in your Webpack build. +- The plugin supports the atomic loader's `moduleGranularity: 'icon'` mode and + attributes queried modules using their query-free physical sprite paths. + Selected modules must retain transformed in-memory source; the plugin fails + rather than reading the full family from disk and silently restoring sibling + symbol usage. +- Use coordinated loader/plugin releases for icon granularity. A compilation-level + `v1` selector capability handshake rejects missing or incompatible query + support. The SVG-sprite plugin remains Webpack-only. ## Contributing diff --git a/packages/react-icons-svg-sprite-subsetting-webpack-plugin/package.json b/packages/react-icons-svg-sprite-subsetting-webpack-plugin/package.json index 2e54636a13a..c8a5af91fb2 100644 --- a/packages/react-icons-svg-sprite-subsetting-webpack-plugin/package.json +++ b/packages/react-icons-svg-sprite-subsetting-webpack-plugin/package.json @@ -4,12 +4,13 @@ "description": "Webpack plugin to subset or merge SVG sprite assets used by @fluentui/react-icons svg-sprite APIs.", "main": "lib/index.js", "scripts": { - "test": "yarn test:atomic && yarn test:merged && yarn test:manifest && yarn test:inline && yarn test:reference && yarn test:validation", + "test": "yarn test:atomic && yarn test:merged && yarn test:manifest && yarn test:inline && yarn test:reference && yarn test:icon-granularity && yarn test:validation", "test:atomic": "yarn run -T webpack -c test/webpack.config.js", "test:merged": "SVG_SPRITE_MODE=merged SVG_SPRITE_MERGED_FILENAME='fluentui-react-icons.[contenthash].svg' yarn run -T webpack -c test/webpack.config.js", "test:manifest": "SVG_SPRITE_MANIFEST=1 yarn run -T webpack -c test/webpack.config.js", "test:inline": "SVG_SPRITE_INJECT=inline yarn run -T webpack -c test/webpack.config.js", "test:reference": "SVG_SPRITE_MODE=merged SVG_SPRITE_INJECT=reference yarn run -T webpack -c test/webpack.config.js", + "test:icon-granularity": "SVG_SPRITE_ICON_GRANULARITY=1 yarn run -T webpack -c test/webpack.config.js", "test:validation": "node test/validation.js", "build": "node scripts/build", "lint": "yarn run -T eslint package.json", diff --git a/packages/react-icons-svg-sprite-subsetting-webpack-plugin/src/index.ts b/packages/react-icons-svg-sprite-subsetting-webpack-plugin/src/index.ts index 4eb697a1d34..20cf6c5a77c 100644 --- a/packages/react-icons-svg-sprite-subsetting-webpack-plugin/src/index.ts +++ b/packages/react-icons-svg-sprite-subsetting-webpack-plugin/src/index.ts @@ -4,6 +4,7 @@ import { readFileSync } from 'fs'; import type { Schema } from 'schema-utils/declarations/validate'; import { validate } from 'schema-utils'; import MergedSpriteRuntimeModule from './runtime/MergedSpriteRuntimeModule'; +import { assertSupportedSelector, hasFluentSelector, registerSelectorCapability } from './selector-protocol'; import optionsSchema from './options.schema.json'; @@ -116,6 +117,7 @@ export default class FluentUIReactIconsSvgSpriteSubsettingPlugin implements webp } compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + registerSelectorCapability(compilation); let entrypointToSpriteResourceToIds: Map>> | null = null; let spriteResourceToAssetName: Map | null = null; let mergedSpriteSvg: string | null = null; @@ -652,10 +654,11 @@ function isFluentUIReactSvgSpriteEntrypointModule(m: webpack.Module): m is webpa return false; } - const resource = m.resource; + const resource = getPhysicalResource(m.resource); if (!resource) { return false; } + assertSupportedSelector(m.resource); // Cheap pre-filter before regex if (!resource.includes('react-icons')) { @@ -679,8 +682,15 @@ function getModuleSource(m: webpack.NormalModule): string { return src.toString(); } - // Fallback (should be rare) - return readFileSync(m.resource, 'utf8'); + if (hasFluentSelector(m.resource)) { + throw new Error( + `${PLUGIN_NAME}: transformed source is unavailable for selected module "${m.resource}"; ` + + `refusing to read the full family module from disk.`, + ); + } + + // Family-mode fallback. + return readFileSync(getPhysicalResource(m.resource), 'utf8'); } /** @@ -692,19 +702,24 @@ function getReferencedSpritePath(module: webpack.NormalModule, moduleSource: str const esm = moduleSource.match(/import\s+\w+\s+from\s+['"](.+?\.svg)['"];?/); const rawPath = esm?.[1]; if (rawPath) { - return resolve(dirname(module.resource), rawPath); + return resolve(dirname(getPhysicalResource(module.resource)), rawPath); } // CJS form: `var sprite = require('./backpack.svg');` const cjs = moduleSource.match(/require\(['"](.+?\.svg)['"]\)/); const rawPath2 = cjs?.[1]; if (rawPath2) { - return resolve(dirname(module.resource), rawPath2); + return resolve(dirname(getPhysicalResource(module.resource)), rawPath2); } return null; } +function getPhysicalResource(resource: string): string { + const queryIndex = resource.indexOf('?'); + return queryIndex === -1 ? resource : resource.slice(0, queryIndex); +} + /** * Parses a sprite entrypoint module's source to build a mapping from exported * component names to their corresponding SVG `` IDs. diff --git a/packages/react-icons-svg-sprite-subsetting-webpack-plugin/src/selector-protocol.ts b/packages/react-icons-svg-sprite-subsetting-webpack-plugin/src/selector-protocol.ts new file mode 100644 index 00000000000..f2011eaf0ef --- /dev/null +++ b/packages/react-icons-svg-sprite-subsetting-webpack-plugin/src/selector-protocol.ts @@ -0,0 +1,32 @@ +import type * as webpack from 'webpack'; + +const PLUGIN_NAME = 'FluentUIReactIconsSvgSpriteSubsettingPlugin'; +const SELECTOR_QUERY_KEY = '__fluentIcon'; +const SELECTOR_PROTOCOL_VERSION = 'v1'; +const SELECTOR_PROTOCOL_IDENTIFIER = `${SELECTOR_QUERY_KEY}=${SELECTOR_PROTOCOL_VERSION}`; +const SELECTOR_CAPABILITY = Symbol.for(`fluentui.react-icons.selector-protocol:${SELECTOR_PROTOCOL_IDENTIFIER}`); +const SELECTOR_PROTOCOL_PATTERN = new RegExp(`(?:\\?|&)${SELECTOR_QUERY_KEY}=([^&]+)`); + +export function hasFluentSelector(resource: string): boolean { + return SELECTOR_PROTOCOL_PATTERN.test(resource); +} + +export function assertSupportedSelector(resource: string): void { + const match = SELECTOR_PROTOCOL_PATTERN.exec(resource); + if (match && match[1] !== SELECTOR_PROTOCOL_VERSION) { + throw new Error( + `${PLUGIN_NAME}: unsupported Fluent icon selector protocol "${match[1]}" ` + + `(expected "${SELECTOR_PROTOCOL_VERSION}") in "${resource}".`, + ); + } +} + +export function registerSelectorCapability(compilation: webpack.Compilation): void { + const target = compilation as unknown as Record; + let capabilities = target[SELECTOR_CAPABILITY] as Set | undefined; + if (!capabilities) { + capabilities = new Set(); + target[SELECTOR_CAPABILITY] = capabilities; + } + capabilities.add('svg-sprite'); +} diff --git a/packages/react-icons-svg-sprite-subsetting-webpack-plugin/test/webpack.config.js b/packages/react-icons-svg-sprite-subsetting-webpack-plugin/test/webpack.config.js index 9b7569649c9..4ab66828a74 100644 --- a/packages/react-icons-svg-sprite-subsetting-webpack-plugin/test/webpack.config.js +++ b/packages/react-icons-svg-sprite-subsetting-webpack-plugin/test/webpack.config.js @@ -8,6 +8,7 @@ const isMerged = process.env.SVG_SPRITE_MODE === 'merged'; const injectMode = process.env.SVG_SPRITE_INJECT; const generateManifest = process.env.SVG_SPRITE_MANIFEST === '1'; const mergedSpriteFilename = process.env.SVG_SPRITE_MERGED_FILENAME; +const useIconGranularity = process.env.SVG_SPRITE_ICON_GRANULARITY === '1'; const entryName = isMerged ? 'merged' : 'atomic'; const hasHtmlInjection = injectMode === 'inline' || injectMode === 'reference'; @@ -41,6 +42,22 @@ module.exports = { }, module: { rules: [ + // Direct atomic imports already exercise the normal family-module path without the loader. + // Enable it only to cover the distinct query-selected identities produced by icon granularity. + ...(useIconGranularity + ? [ + { + test: /\.js$/, + enforce: 'pre', + use: [ + { + loader: resolve(__dirname, '../../react-icons-atomic-webpack-loader/lib/index.js'), + options: { iconVariant: 'svg-sprite', moduleGranularity: 'icon' }, + }, + ], + }, + ] + : []), { test: /\.svg$/, type: 'asset/resource', @@ -75,6 +92,23 @@ module.exports = { apply(compiler) { compiler.hooks.afterEmit.tap('test-svg-sprite-subsetting', (compilation) => { const outDir = compilation.outputOptions.path || resolve(__dirname, 'dist'); + if (useIconGranularity) { + const spriteModules = Array.from(compilation.modules) + .map((module) => module.resource) + .filter( + (resource) => + typeof resource === 'string' && + /[\\/]react-icons[\\/]lib[\\/]atoms[\\/]svg-sprite[\\/].+\.js(?:\?|$)/.test(resource), + ); + // Each imported icon must have its own query-selected module identity. + if (spriteModules.filter((resource) => resource.includes('?__fluentIcon=v1&export=')).length !== 2) { + throw new Error(`Expected two queried sprite modules, found: ${spriteModules.join(', ')}`); + } + // An additional unqueried module would include the complete icon family and defeat icon granularity. + if (spriteModules.some((resource) => !resource.includes('?__fluentIcon='))) { + throw new Error(`Found an unqueried sprite family module in icon mode: ${spriteModules.join(', ')}`); + } + } const svgAssets = compilation .getAssets() .map((a) => a.name) diff --git a/packages/react-icons/build-verify.test.js b/packages/react-icons/build-verify.test.js index a981088d09c..2ba9baf5a91 100644 --- a/packages/react-icons/build-verify.test.js +++ b/packages/react-icons/build-verify.test.js @@ -2089,6 +2089,46 @@ describe('Build Verification', () => { return { svgPathEsm, svgPathCjs, fontsPathEsm, fontsPathCjs }; } + it('keeps every generated ESM declaration independently selectable', async () => { + const atomRoot = path.join(__dirname, 'lib/atoms'); + const atomDirectories = ['svg', 'fonts', 'headless-svg', 'headless-fonts', 'svg-sprite']; + + for (const directory of atomDirectories) { + const directoryPath = path.join(atomRoot, directory); + if (!fs.existsSync(directoryPath)) continue; + + for (const filename of await readdir(directoryPath)) { + if (!filename.endsWith('.js')) continue; + const source = await readFile(path.join(directoryPath, filename), 'utf8'); + const lines = source.split('\n').filter(Boolean); + const exportNames = lines + .map((line) => /^export const ([A-Za-z_$][\w$]*)\s*=/.exec(line)?.[1]) + .filter(Boolean); + const exportNameSet = new Set(exportNames); + + expect(exportNames.length, `${directory}/${filename} must export icon declarations`).toBeGreaterThan(0); + + for (const line of lines) { + expect( + /^(?:"[^"]+";|import .+;|\/\*\*.*\*\/|export const [A-Za-z_$][\w$]*\s*=.+;)$/.test(line), + `${directory}/${filename} contains unsupported top-level syntax: ${line}`, + ).toBe(true); + } + + for (const line of lines.filter((value) => value.startsWith('export const '))) { + const ownName = /^export const ([A-Za-z_$][\w$]*)\s*=/.exec(line)?.[1]; + const siblingReference = line + .match(/[A-Za-z_$][\w$]*/g) + ?.find((identifier) => identifier !== ownName && exportNameSet.has(identifier)); + expect( + siblingReference, + `${directory}/${filename}:${ownName} depends on sibling export ${siblingReference}`, + ).toBeUndefined(); + } + } + } + }, 30_000); + it(`should have same number of atoms/svg icon files in lib and lib-cjs`, async () => { const { svgPathCjs, svgPathEsm } = getAssetPaths(); const esmStats = await getAtomDirStats(svgPathEsm); diff --git a/yarn.lock b/yarn.lock index be97b42f5f4..f5844ca8814 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3901,6 +3901,7 @@ __metadata: resolution: "@fluentui/react-icons-atomic-webpack-loader@workspace:packages/react-icons-atomic-webpack-loader" dependencies: "@fluentui/react-icons": "npm:*" + "@jridgewell/remapping": "npm:^2.3.5" magic-string: "npm:^0.30.0" oxc-parser: "npm:^0.125.0" peerDependencies: