From 44af54156eeb6ef4f6fcb746002e5444a3a271a2 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 3 Sep 2026 15:46:46 +0200 Subject: [PATCH 1/2] test(react-icons-font-subsetting-webpack-plugin): cover async chunks Every fixture kept its icons in the entry chunk, so nothing verified that a font reachable only through `import()` is still found and subset. The size ceiling cannot police this alone: dropping the async chunk's glyph makes the font smaller, so it slips under any threshold. The two families are therefore also asserted to retain a glyph beyond .notdef, read from the maxp table. --- .../test/make-configs.js | 57 ++++++++++++++++++- .../test/src/lazy-atoms.async.js | 5 ++ .../test/src/lazy-atoms.js | 8 +++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-atoms.async.js create mode 100644 packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-atoms.js 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..f9be61425c 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,14 @@ 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 both families are also asserted to retain a real glyph. + lazyAtoms: { + src: './src/lazy-atoms.js', + threshold: 2 * 1_024, // 2 KB + fontsWithGlyphs: ['FluentSystemIcons-Resizable', 'FluentSystemIcons-Filled'], + }, }; /** @@ -138,7 +147,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, fontsWithGlyphs?: string[] }} entry * @param {BundlerAdapter} adapter */ function createAssertionPlugin(name, entry, adapter) { @@ -160,6 +169,27 @@ function createAssertionPlugin(name, entry, adapter) { } } + for (const fontBaseName of entry.fontsWithGlyphs ?? []) { + // 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))); + // Every subset keeps .notdef, so a font stripped of all real glyphs still reports 1. + if (glyphCount < 2) { + throw new Error( + `[${adapter.name}/${name}] Asset "${asset.name}" contains no glyphs beyond .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 +207,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)); From 7756fa28f5c836c75c9018cd887367c87995662f Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 3 Sep 2026 16:30:51 +0200 Subject: [PATCH 2/2] test(react-icons-font-subsetting-webpack-plugin): cover one family split across chunks The lazyAtoms fixture puts its eager and async icons in different font families, so nothing yet proved that a single emitted font can carry glyphs contributed by two different chunks. Subsetting is per family across the whole build, not per chunk, so the eager half must not subset the async half's glyph away. Glyph assertions become counts rather than a mere presence check, since 'at least one glyph' cannot tell two icons from one. --- .../test/make-configs.js | 23 ++++++++++++------- .../test/src/lazy-shared-family.async.js | 5 ++++ .../test/src/lazy-shared-family.js | 8 +++++++ 3 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-shared-family.async.js create mode 100644 packages/react-icons-font-subsetting-webpack-plugin/test/src/lazy-shared-family.js 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 f9be61425c..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 @@ -32,11 +32,19 @@ const entries = { }, // 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 both families are also asserted to retain a real glyph. + // 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 - fontsWithGlyphs: ['FluentSystemIcons-Resizable', 'FluentSystemIcons-Filled'], + 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 }, }, }; @@ -147,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, fontsWithGlyphs?: string[] }} entry + * @param {{ threshold: number, assertNoGriffel?: boolean, fontGlyphCounts?: Record }} entry * @param {BundlerAdapter} adapter */ function createAssertionPlugin(name, entry, adapter) { @@ -169,7 +177,7 @@ function createAssertionPlugin(name, entry, adapter) { } } - for (const fontBaseName of entry.fontsWithGlyphs ?? []) { + 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), @@ -181,11 +189,10 @@ function createAssertionPlugin(name, entry, adapter) { // `afterEmit` downgrades sources to size-only, so the bytes come back off disk. const glyphCount = readGlyphCount(readFileSync(join(compiler.outputPath, asset.name))); - // Every subset keeps .notdef, so a font stripped of all real glyphs still reports 1. - if (glyphCount < 2) { + if (glyphCount < expectedGlyphs) { throw new Error( - `[${adapter.name}/${name}] Asset "${asset.name}" contains no glyphs beyond .notdef — ` + - `an icon that should have been kept was subset away.`, + `[${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.`, ); } } 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));