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 def7b1de4e..3b966888fd 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 @@ -5,7 +5,8 @@ * The same entries, thresholds and assertions run against both webpack and rspack; only the * CSS-extraction and HTML plugins differ, so they are injected by the bundler-specific configs. */ -const { resolve } = require('path'); +const { resolve, join } = require('path'); +const { readFileSync } = require('fs'); const { default: FluentUIReactIconsFontSubsettingPlugin } = require('../lib/'); @@ -29,6 +30,22 @@ const entries = { useAtomicLoader: true, assertNoGriffel: true, }, + // Async chunks: one icon is eager, the other reachable only through `import()`, and they live in + // different font families. The size ceiling cannot police this on its own — losing the async + // glyph makes the font *smaller* — so glyph counts are asserted too (`.notdef` is always glyph 0). + lazyAtoms: { + src: './src/lazy-atoms.js', + threshold: 2 * 1_024, // 2 KB + fontGlyphCounts: { 'FluentSystemIcons-Resizable': 2, 'FluentSystemIcons-Filled': 2 }, + }, + // The harder variant: both icons are sized+Filled, so a *single* emitted font must carry glyphs + // contributed by two different chunks. Fonts are subset per family across the whole build, not + // per chunk, so the eager half must not subset the async half's glyph away. + lazySharedFontFamily: { + src: './src/lazy-shared-family.js', + threshold: 2 * 1_024, // 2 KB + fontGlyphCounts: { 'FluentSystemIcons-Filled': 3 }, + }, }; /** @@ -138,7 +155,7 @@ function createConfig(name, entry, adapter, isDevServer) { * Fails the build when a font asset was not subset, or when a headless entry leaked Griffel. * * @param {string} name - * @param {{ threshold: number, assertNoGriffel?: boolean }} entry + * @param {{ threshold: number, assertNoGriffel?: boolean, fontGlyphCounts?: Record }} entry * @param {BundlerAdapter} adapter */ function createAssertionPlugin(name, entry, adapter) { @@ -160,6 +177,26 @@ function createAssertionPlugin(name, entry, adapter) { } } + for (const [fontBaseName, expectedGlyphs] of Object.entries(entry.fontGlyphCounts ?? {})) { + // Only .ttf is inspected; .woff/.woff2 wrap the same glyphs in a compressed container. + const asset = fontAssets.find(({ name: assetName }) => + new RegExp(`^${fontBaseName}[.-][^/]*\\.ttf$`).test(assetName), + ); + + if (!asset) { + throw new Error(`[${adapter.name}/${name}] No emitted .ttf asset for "${fontBaseName}".`); + } + + // `afterEmit` downgrades sources to size-only, so the bytes come back off disk. + const glyphCount = readGlyphCount(readFileSync(join(compiler.outputPath, asset.name))); + if (glyphCount < expectedGlyphs) { + throw new Error( + `[${adapter.name}/${name}] Asset "${asset.name}" has ${glyphCount} glyphs, expected at least ` + + `${expectedGlyphs} (including .notdef) — an icon that should have been kept was subset away.`, + ); + } + } + // Headless builds must not pull in Griffel. if (entry.assertNoGriffel) { for (const m of compilation.modules) { @@ -177,4 +214,27 @@ function createAssertionPlugin(name, entry, adapter) { }; } +/** + * Reads `numGlyphs` out of a TrueType font's `maxp` table. + * + * Byte sizes make a poor correctness signal here: a font that wrongly dropped a glyph is *smaller*, + * so it slips under any ceiling. The glyph count says outright whether an icon survived. + * + * @param {Buffer} ttf + * @returns {number} + */ +function readGlyphCount(ttf) { + const tableCount = ttf.readUInt16BE(4); + + for (let i = 0; i < tableCount; i++) { + // Table directory: 12-byte header, then 16 bytes per record (tag, checksum, offset, length). + const record = 12 + i * 16; + if (ttf.toString('ascii', record, record + 4) === 'maxp') { + return ttf.readUInt16BE(ttf.readUInt32BE(record + 8) + 4); + } + } + + throw new Error('Font has no `maxp` table — not a TrueType font?'); +} + module.exports = { makeConfigs, entries }; diff --git a/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-atoms.async.js b/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-atoms.async.js new file mode 100644 index 0000000000..41f1a3589e --- /dev/null +++ b/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-atoms.async.js @@ -0,0 +1,5 @@ +// @ts-check +// Reached only via `import()`, so its icon exists solely in an async chunk. +import { XboxConsole24Filled } from '@fluentui/react-icons/fonts/xbox-console'; + +export { XboxConsole24Filled }; diff --git a/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-atoms.js b/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-atoms.js new file mode 100644 index 0000000000..60dcbcabd2 --- /dev/null +++ b/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-atoms.js @@ -0,0 +1,8 @@ +// @ts-check +// The eager half. Its sibling is reachable only through a dynamic import, so the two icons land in +// different chunks — and, deliberately, in different font families. +import { GamesFilled } from '@fluentui/react-icons/fonts/games'; + +console.dir({ GamesFilled }); + +import('./lazy-atoms.async').then((m) => console.dir(m)); diff --git a/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-shared-family.async.js b/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-shared-family.async.js new file mode 100644 index 0000000000..faebff9945 --- /dev/null +++ b/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-shared-family.async.js @@ -0,0 +1,5 @@ +// @ts-check +// Shares the Filled family with the eager half, but is reachable only through `import()`. +import { XboxConsole24Filled } from '@fluentui/react-icons/fonts/xbox-console'; + +export { XboxConsole24Filled }; diff --git a/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-shared-family.js b/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-shared-family.js new file mode 100644 index 0000000000..861a39389d --- /dev/null +++ b/packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-shared-family.js @@ -0,0 +1,8 @@ +// @ts-check +// Same font family as its async sibling: both icons are sized+Filled, so one emitted font file has +// to carry glyphs contributed from two different chunks. +import { Games24Filled } from '@fluentui/react-icons/fonts/games'; + +console.dir({ Games24Filled }); + +import('./lazy-shared-family.async').then((m) => console.dir(m));