feat(core): Add only-include-used-components: opt-in trimming of unused DSFR component CSS - #505
Conversation
… CSS Opt-in script, modeled after only-include-used-icons, that rebuilds dsfr.css and dsfr.min.css in node_modules (and public/dsfr when applicable) with only the CSS of the DSFR components actually used by the project, plus the core and scheme which are always included. Usage is detected from @codegouvfr/react-dsfr/<Component> imports and from raw fr-* class names found in the sources. Components can also be forced via "react-dsfr"."additionalComponents" in package.json. Any unknown component import falls back to including every component. The stylesheets are rebuilt from the granular files shipped in dsfr/ (core, scheme, component/*, print variants) preserving the upstream cascade order, rewriting relative asset urls and reapplying the Mui compat patch, so no individual CSS rule is ever dropped or rewritten. See codegouvfr#304
Covers import detection, module to DSFR components resolution, raw class name detection and stylesheet generation (cascade order, url rewriting, charset stripping, Mui compat patch, determinism).
A direct import of an unrecognized dsfr/component/<x> stylesheet, and any unknown lowercase-starting react-dsfr module, returned [] instead of undefined. This silently skipped the "include every component" fail-safe and its warning for modules this script does not know about, instead of only affecting genuinely non-component modules.
There was a problem hiding this comment.
Pull request overview
This PR adds a new opt-in CLI script (only-include-used-components) to rebuild DSFR CSS bundles by concatenating only the granular component stylesheets that correspond to components detected as used in the target codebase, reducing unused CSS while keeping DSFR JS runtime class toggles safe.
Changes:
- Add
src/bin/only-include-used-components.tsplus CLI wiring viareact-dsfrand a dedicated bin entry. - Implement component usage detection (react-dsfr imports, raw
fr-*class prefixes, andpackage.jsonescape hatch) and rebuilddsfr.css/dsfr.min.css(and SPA public patch + hash busting). - Add unit tests covering module resolution, detection, URL rewriting, MUI core patching, and CSS generation behavior.
Reviewed changes
Copilot reviewed 5 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts | Adds unit tests for moduleId→DSFR component mapping and fail-safe behavior. |
| test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts | Adds unit tests for detecting react-dsfr module IDs from source text. |
| test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts | Adds unit tests for CSS reconstruction (order, inclusion/exclusion, URL rewriting, MUI patching). |
| test/runtime/scripts/onlyIncludeUsedComponents/detectDsfrComponentsFromClassNames.test.ts | Adds unit tests for detecting DSFR components via raw fr-* class usage. |
| src/bin/react-dsfr.ts | Wires the new command into the react-dsfr CLI dispatcher. |
| src/bin/only-include-used-components.ts | Implements the new trimming/rebuild script and supporting helpers/constants. |
| package.json | Exposes only-include-used-components as a published bin entry. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
lsagetlethias
left a comment
There was a problem hiding this comment.
Nice work, and thanks for the very detailed description. The approach is the right one: rebuilding from the granular stylesheets instead of purging rules is what makes this safe against the classes the DSFR JS toggles at runtime, and it is a much better answer to #304 than a PurgeCSS pass. The CLI ergonomics matching only-include-used-icons is the right call too.
I checked the core claims independently against @gouvfr/dsfr 1.14.2 rather than taking them on trust, and they hold:
- The reconstruction is faithful. Rebuilding
core + scheme + component/*and diffing rule by rule againstdsfr.main.css + dsfr.print.css(the correct target, sincescripts/build/build.ts:58already overwritesdsfr/dsfr.csswith that concatenation) gives 4031 rules vs 4030, the only delta being the dark-mode block split in two. Exactly what you documented. - The Mui patch is equivalent to the real one.
button:not(:disabled):hover|activeoccurs twice, both incore, both inside(hover: hover) and (pointer: fine). The negative lookahead also makes it safely re-runnable. - URL rewriting is correct on the real
url()set, and the static tables cover all 66 current entry points, so nothing falls through to the fail-safe today. - The numbers reproduce: 581 kB -> 282 kB, 76 kB -> 36 kB gzip on a 5-component app, idempotent on the second run, fail-safe firing correctly on an unknown module.
Two things I would want fixed before merge, left as inline comments: the assets referenced by the regenerated public/dsfr/dsfr.min.css are never copied (404s in production, with a repro), and the fail-safe is currently far too easy to trigger, which silently reverts to the full bundle.
The rest is polish and could land here or as a follow-up:
-
transcriptionis at the wrong index inDSFR_COMPONENTS_CASCADE_ORDER(line 83). Upstream section banners indsfr.main.cssread... TABLE TRANSCRIPTION HEADER, so it belongs between line 98 and line 99, not right aftercontent. I could not exhibit any rendering difference (see point 2), but the array's whole purpose is fidelity. A test asserting it equals the section order extracted from the installeddsfr.main.csswould stop it drifting on the next DSFR bump, and would be cheap given the banners are machine-readable. -
"The upstream cascade order is preserved" is not quite accurate. The upstream bundle groups rules by media context globally (all unmediated rules from every component, then all
@media (min-width: 36em), etc.), while the concatenation groups them by component, which displaces about 29% of the rules. I looked for actual damage and found none: no selector is declared with divergent values in two different granular files, so there is no equal-specificity conflict whose winner can flip. Worth rewording in the description rather than changing the code, since the empirical validation you ran covered the rule set and not the order. -
shared(line 242) andlink(line 240) are inNON_COMPONENT_MODULE_IDSbut do render DSFR markup.src/shared/Fieldset.tsxemitsfr-fieldset,fr-fieldset__legend,fr-label,fr-hint-text(form) plusfr-radio-rich,fr-radio-rich__img(radio), andsrc/link.tsx:40,69rendersfr-link. Both resolve to[], which suppresses the fail-safe instead of triggering it. Exposure is low (undocumented internal subpaths, and the sanctioned wrappers are mapped correctly), but it is the same class of hole your last commit set out to close. Mappingsharedto["form", "radio", "checkbox"]andlinkto["link"]would close it. -
additionalComponentsentries mapping to[]report the opposite of what happens (lines 881-900)."Chart"(line 124) is the only one today: the run printsIncluding Chart (from package.json additionalComponents)and adds nothing. Someone reaching for that escape hatch is doing so precisely because a component is unstyled, so a misleading confirmation is the worst possible feedback. Worth logging explicitly that the entry maps to no DSFR stylesheet (the Chart CSS comes from@gouvfr/dsfr-chart). -
fs.readdirSyncondsfr/component(line 790) has no guard and dumps a rawENOENT ... scandirstack. The nominal case is fine since the published package ships the folder, but a workspace-linked or prunednode_modulesgets an unreadable error. AnexistsSync+assert(false, "...")in the same register as line 485 would be consistent with the rest of the script. -
Two pre-existing behaviours this script inherits verbatim from
only-include-used-icons, so not regressions of this PR, but worth a thought since it is proposed as aprebuildstep for everyone:assert(htmlFilePath !== undefined)(line 769) throws a message-lessAssertionErroras soon aspublic/dsfrexists without anindex.html. That is every Next.js project that has runcopy-static-assets. I reproduced it, and reproduced the identical crash withonly-include-used-icons. MakinghtmlFilePathoptional inspaParamsand skippingaddHashQueryParameterInIndexHtmlwould let those projects benefit from the trim instead of crashing.- The
hasChangedearly return (line 986) also skips theindex.htmlhash rewrite, so a stale or hand-reverted hash can never be repaired while the CSS itself is unchanged. The rewrite is already idempotent, so it could simply live outside the guard.
-
Documentation.
src/bin/README.mdis a single line and nothing there covers this script;additionalComponentsis only described in a source docblock, where a typo in the key silently disables the whole thing. Given the fail-safe makes misconfiguration invisible, a short section on the website (or at least in the README) explaining the ordering constraint withcopy-static-assetswould go a long way.
Happy to re-review once the two inline points are addressed.
Lilian & Claude 🤖
Copy the assets referenced by the regenerated public/dsfr/dsfr.min.css. copy-dsfr-to-public builds its keep list from the url() of the dsfr.min.css it finds in node_modules, then early returns as long as public/dsfr/version.txt matches the @gouvfr/dsfr version. Once it had run against an already trimmed stylesheet, public/dsfr was frozen on that asset subset and growing the component set later could never bring the missing files back (blank burger, close, search and alert icons in production). The copy runs outside the `hasChanged` guard so a purged public/dsfr is repairable even when the CSS itself did not change. Stop triggering the include-everything fail-safe on non imports. Stylesheets are no longer scanned (class name detection is substring based, so a single compiled bundle in out/ or in a dependency marked every component as used) and module ids are now read from actual import specifiers only, instead of any textual occurrence: a link to https://www.npmjs.com/package/@codegouvfr/react-dsfr/v/1.32.5 in an .mdx used to resolve to the module "v" and silently ship the full bundle. Add --strict to exit 1 instead of falling back, for CI where the warning goes unnoticed and the run still looks like a success. Move `transcription` between `table` and `header` in DSFR_COMPONENTS_CASCADE_ORDER, and assert the whole array against the section order extracted from the `sources` of the installed dsfr.main.css.map so it can't drift on a DSFR bump. Map `link` and `shared` to the components they render (fr-link for the Link fallback, fr-fieldset/fr-label/fr-hint-text/fr-radio-rich for Fieldset) instead of resolving them to no component at all, which suppressed the fail-safe. Report `additionalComponents` entries that map to no DSFR stylesheet for what they are instead of confirming an inclusion that does not happen, warn on a typo in the `additionalComponents` key, and give the missing dsfr/component directory a proper message instead of a raw ENOENT. Document the three bin scripts in src/bin/README.md, including the ordering constraint with copy-static-assets.
|
Thank you @lsagetlethias for this review — the independent verification of the reconstruction, of the Mui patch and of the numbers is far more than I expected, and both inline points were real. All addressed in 6d268d0, except point 6 (see below). Inline 1 — assets. Inline 2 — fail-safe surface. Took the simpler of your two options: 1 — 2 — reworded in the description, code unchanged, keeping your finding that the reordering is inert. 3 — 4 — 5 — 6 — agreed on both. They're going into a separate PR, opened right after this one, since the fix has to touch 7 — Edit: corrected the last sentence of point 6 — I had claimed |
lsagetlethias
left a comment
There was a problem hiding this comment.
LGTM. Re-checked everything by running it, not by reading:
- Assets: replayed the repro,
public/dsfrgoes 12 -> 19 svg,menu-fill.svgpresent, 0 of 38 urls missing on disk. Same numbers you report. - Fail-safe: the two URLs in an
.mdxand a DSFR bundle dropped inout/assets/both leave the output at 9/45. All eight import forms still resolve (named, default, side-effect,dynamic(() => import()),require, re-export,blocks/,@import).--strictexits 1 and writes nothing. - Cascade order: verified with a third method, the median position in the bundle of each stylesheet's own rules. All 45 match,
radioincluded. Your test is not vacuous either, movingtranscriptionback fails it on that entry. - No regression in generation: the only delta against upstream is the two Mui-patched selectors (intended) and the split dark block (documented).
Two non-blocking notes, both one-liners, take them or leave them.
The section banners do exist. dsfr.main.css has 44, in the form /* ¯¯¯¯¯¯¯¯¯ *\ + name + \* ˍˍˍˍˍˍˍˍˍ */, which is where my ... TABLE TRANSCRIPTION HEADER came from. Your source map choice is still the better one, but for a reason you did not give: the banners are a defective index. badge, consent, notice and radio have none, and notice is labelled ALERT, so the list has alert twice. The map only misses radio. Worth fixing the claim in the test docblock ("There is no section banner in dsfr.main.css to read it from"), otherwise the next person loses a usable cross-check.
The cascade order test passes green when the source map is missing. I moved dsfr.main.css.map out of the way and it reports success. That is the same shape as what you just fixed with --strict: a warning nobody reads plus exit 0. @gouvfr/dsfr is a direct dependency of this repo, so the map is guaranteed present in CI and there is no legitimate skip case. Failing instead of skipping would keep the guard from silently evaporating on a future DSFR that stops shipping maps.
The section banners do exist in dsfr.main.css, my test docblock claimed otherwise. They are however a defective index, which is the real reason to prefer the source map: 44 banners for 45 components, `badge`, `consent`, `notice` and `radio` have none, and `notice` is labelled ALERT so the list contains `alert` twice. The map only misses `radio`. Docblock corrected to say that instead. Fail the cascade order test when dsfr.main.css.map is missing, instead of warning and reporting success. @gouvfr/dsfr is a direct dependency of this repo so there is no legitimate skip case, and a green skip would let the guard silently evaporate on a future DSFR that stops shipping source maps. Also apply here the fix sent for only-include-used-icons in codegouvfr#506, since this file does not exist on main yet: `spaParams.htmlFilePath` becomes optional so a public/dsfr without a findable index.html no longer throws a message-less AssertionError, and the cache busting rewrite moves out of the `hasChanged` early return so a stale or hand reverted hash is repairable.
|
Thanks for the approval, and for re-running everything rather than reading it — the third method on the cascade order (median rule position) is a better check than mine. Both notes were right, both fixed in 287c641. The banners. You're right and my claim was wrong — 44 of them, my grep had missed them because the banner body contains Green skip. Fixed, and you're right that it's the same shape as the thing I had just added While I was in there I also applied the #506 fix to this script, since #506 is open for the |
Next.js has no `public/dsfr` in the documented setup, `next-appdir` and `next-pagesdir` run the trimming scripts without `copy-dsfr-to-public`, so `spaParams` is undefined there and this field is never reached. The real trigger is any project with a `public/dsfr` and no `index.html`. Same correction as on fix/skip-index-html-when-absent, the comment was copied from there.
It cannot produce this state: copy-dsfr-to-public.ts:60 asserts "Can't locate your index.html file." before the mkdirSync at :95, so a project with no index.html never gets a public/dsfr out of it, Next.js included.
|
Two small follow-ups pushed here as a consequence of @lsagetlethias's review of #506, both on the same
For the record, the two substantive findings of that review do not apply here, checked explicitly:
103 tests, eslint and prettier clean. |
|
First, apologies to @kevbarns: I took too long to review, and it was merged before I had properly considered the public API. The implementation itself is solid and remains unchanged in principle. It safely rebuilds the CSS from complete DSFR component stylesheets instead of purging individual rules, addressing the performance concern raised by @revolunet in [#304](#304). Thanks also to @lsagetlethias for the particularly thorough review and independent verification. I have released a follow-up API cleanup in What changedThe new canonical command is: npx react-dsfr optimize-cssIt sequentially:
The internal implementation was renamed from The following APIs were removed: npx react-dsfr only-include-used-components
npx only-include-used-components
RationaleI want
Breaking change and migrationThis only affects projects that adopted the new component command introduced in Replace: {
"scripts": {
"predev": "react-dsfr update-icons && react-dsfr only-include-used-components",
"prebuild": "react-dsfr update-icons && react-dsfr only-include-used-components"
}
}with: {
"scripts": {
"predev": "react-dsfr optimize-css",
"prebuild": "react-dsfr optimize-css"
}
}For CRA setups that copy the static assets: {
"scripts": {
"prebuild": "react-dsfr copy-static-assets && react-dsfr optimize-css"
}
}
Documentation and validationThe CLI documentation and the [v1 documentation](https://github.com/codegouvfr/react-dsfr/tree/v1_docs) have been updated. The following examples and satellite projects were migrated to
Again, thank you @kevbarns for the substantial work behind this feature. This follow-up changes its public entry point, not its implementation or the approach established by #505. |
Reconcile with the rework of the external API of #505: - `src/bin/only-include-used-components.ts` was renamed to `only-include-css-of-used-components.ts` and the `only-include-used-components` subcommand folded into `optimize-css`. The spacing trimming additions follow the rename; the `--trim-spacing-utilities` flag is now an `optimize-css` flag (yargs-parser ignores it in the icons stage that `optimize-css` runs first). - Update the command name in `src/bin/README.md` and in the `trimSpacingUtilities.ts` header.
|
Hello @garronej, thank you for your feedback and no problem, happy to help out here. — While you're on this area: #509 (closes #508) covers the CSS that #505 structurally can't reach. Component trimming works by including or excluding whole component stylesheets; the spacing utility grid ( On the Vite-shaped test app described in the PR (measured before the 1.34.0 rework): ~78 kB raw / ~11.5 kB gzip on top of component trimming. Without the flag, the output is byte-identical to the untrimmed build. The risky part — rule-level surgery, since there is no granular file here — is moved to react-dsfr's build time: a generated manifest cross-checked against a real CSS parser on the exact published files, plus a hash of those files. At run time, trimming only happens on a hash match; on any drift the stylesheet ships untrimmed with a never-silenced warning, and The PR is currently conflicting with Separate question worth settling now: opt-in indefinitely, or default-on in a future major? |
|
Follow-up on my previous message: #509 is no longer conflicting, and I re-ran the measurements against 1.34.0 rather than leaving them as "measured before the rework". Conflict resolution
The diff between the merge result and One thing I checked rather than assumed: Numbers, re-measured after the reworkSame Vite-shaped app (
77.5 kB raw, 11.4 kB gzip, unchanged by the API rework. 1209 of 1215 spacing rules removed. A second run reports The byte-identity claim is worth being precise about, since it is the one that matters for anyone who does not opt in: without the flag, Green: 156 tests, Still openThe flag currently stays a CLI flag,
Against it: And the other question from my previous message still stands: opt-in indefinitely, or default-on in a future major once it has some mileage? |
Closes #508 With component trimming in place (#505), the floor of the stylesheet is the core, of which ~78 kB raw (~11.5 kB gzip) is the exhaustive fr-m*/fr-p* spacing grid (1215 rules, 2457 classes in dsfr 1.14.2). These classes are never toggled by the DSFR runtime, so they can be trimmed per rule. Since this is the command's first rule level surgery, it ships behind a separate opt-in flag, --trim-spacing-utilities, and the risky part happens at react-dsfr build time: generateSpacingUtilitiesManifest cross-checks the string level extractor against a real CSS parser (a devDependency) on the exact files that get published, and fingerprints them in a generated dsfr/core/spacing-utilities.json. At run time, trimming only happens when the core file's hash matches the manifest; on any mismatch the stylesheet ships untrimmed, with a never-silenced warning (non zero exit in --strict). Used utilities are detected as literal class names in the already crawled sources, plus the build time derived list of utilities react-dsfr's own components render (the crawl excludes the package). Dynamically constructed class names are detected by their static prefix and every utility the prefix can produce is kept: `fr-m${x}` keeps the margins but trims the paddings, `fr-icon-${x}` triggers nothing. The escape hatch is "react-dsfr"."additionalSpacingUtilities" (["fr-mt-2w", "fr-mb-*"]), where a * suffix declares a prefix and acknowledges the matching dynamic construction under --strict. Measured on a Vite-shaped app using Button and two utilities: dsfr.min.css goes from 231.9 kB raw / 31.8 kB gzip to 154.4 kB / 20.4 kB. Without the flag the output is byte identical to the previous release.
|
Hey @kevbarns, First, thank you for all the work you’ve put into this. I don’t currently have the bandwidth for an in-depth implementation review, but I can clarify the product and maintenance direction I would like us to follow. My general principle is to avoid introducing configuration options unless they represent a genuine user preference. There should be one canonical way to configure Every option shifts complexity from the toolkit onto its users and creates additional API surface that we then need to document, test and maintain. The question I ask before introducing an option is:
If it is a genuine preference, an option is appropriate. If it is an opt-in for an optimization that is still somewhat brittle, I think that is a problem. We should either make the optimization reliable enough to enable by default for standard projects, or not ship it yet. I am fine with an escape hatch for the small minority of projects using unusual build systems, provided that the default works reliably for the overwhelming majority. The canonical setupIcon optimization is not meaningfully optional. Including every icon produces an impractically large stylesheet that Vite and Next.js may not be able to process reliably. Therefore, I would like the official documented setup to remain: {
"scripts": {
"predev": "react-dsfr optimize-css",
"prebuild": "react-dsfr optimize-css"
}
}
For projects whose build setup is too unusual for component or spacing optimization, the single fallback should be: {
"scripts": {
"predev": "react-dsfr update-icons",
"prebuild": "react-dsfr update-icons"
}
}This is not a complete opt-out: it deliberately preserves the essential icon optimization while disabling the more advanced CSS trimming. Fine-grained escape hatchesI am much less comfortable exposing configuration like this: {
"react-dsfr": {
"additionalSpacingUtilities": ["fr-mt-2w", "fr-mb-*"],
"additionalComponents": ["table", "Range"]
}
}This exposes too many implementation details:
More importantly, users have no reliable way to know that this configuration is required. They will simply see a page whose styling looks slightly wrong. A fine-grained escape hatch would be more acceptable if we could emit an actionable diagnostic such as:
But for the edge cases we are discussing, we generally cannot detect that something is missing. That makes the option difficult to discover and easy to misuse. I understand why these options were introduced: while implementing the feature, you identified cases static analysis might miss and provided a way to handle them. From the user’s perspective, however, how the stylesheet is assembled should remain an implementation detail. Every additional configuration field becomes permanent public API. The fact that an LLM could inspect the implementation and discover the correct option does not make the API simpler. An agent could just as easily create a source marker file containing the classes that static analysis otherwise misses, without us expanding the permanent configuration surface. So, in short, I would like:
The complexity required to make this reliable should live inside |
|
Hello @garronej, and thanks for the clear direction. #509 is rebased on Agreed on all four points, and I would rather ship this with no new configuration than ship it behind a flag. Two things I measured while reading your message, because they make the concessions cheap. Your marker file idea already works, with no code change and no config field. Detection reads literal class names from the crawled sources ( With the marker file covering the escape hatch, that field's only remaining job is silencing One point needs your call, because it is my condition for enabling this by default. A class referenced only from a stylesheet is dropped silently today, on both axes: with That closes the blind spot I can close. Classes assembled at runtime from CMS or API content stay out of reach, and So, two questions:
Once you confirm, I will rework #509 accordingly. |
Add
only-include-used-components: opt-in trimming of unused DSFR component CSSCloses #304 (or at least addresses its main pain point)
Problem
dsfr.min.cssweighs ~600kB raw / ~76kB gzip and is loaded render-blocking, while most apps use a small subset of the DSFR components. On Lighthouse mobile audits this is consistently flagged as the main "reduce unused CSS" offender (~95% unused on our app, La Bonne Alternance).Classic PurgeCSS-style tree shaking is not safe here because the DSFR JS adds classes and attributes at runtime (
data-fr-js-*,fr-collapse--expanded, ...), as discussed in #304.Approach
A new opt-in script,
only-include-used-components, modeled afteronly-include-used-icons(same CLI ergonomics:--projectDir,--silent, same project/public dir discovery, same cache clearing, same idempotence).Instead of purging individual rules, it rebuilds
dsfr/dsfr.cssanddsfr/dsfr.min.cssinnode_modules(andpublic/dsfr/dsfr.min.css+index.htmlhash busting for SPAs) by concatenating the granular stylesheets already shipped in the package (dsfr/core/*,dsfr/scheme/*,dsfr/component/<name>/*, including the print variants):DSFR_COMPONENTS_CASCADE_ORDER). The upstream bundle additionally groups rules by media context globally (all unmediated rules from every component, then all@media (min-width: 36em), ...) while the concatenation groups them by component, which displaces about 29% of the rules. That reordering is inert here: no selector is declared with divergent values in two different granular files, so there is no equal-specificity conflict whose winner could flip.url(...)asset paths are rewritten from the granular file location to thedsfr/root, so fonts and icons keep resolving, and the assets the regenerated stylesheet references are copied intopublic/dsfr(SPA setups), the wayonly-include-used-iconsdoes for icons.:not([class^="Mui"])onbutton:not(:disabled):hover/active, cfscripts/build/patchCssForMui.ts) is reapplied to the core chunk, string-based so the script needs no new runtime dependency.I validated the reconstruction empirically against the shipped bundle: with all components selected,
core.main.css + scheme.css + component/*/*.main.css + print variantscovers 100% of the rules ofdsfr.main.css + dsfr.print.css(the only structural difference is the dark-mode custom properties being declared in two blocks instead of one merged block).Detection of used components
@codegouvfr/react-dsfr/<Module>specifier in an actual import statement (from "...",import "...",import("..."),require("..."),@import "..."), resolved through a static tableREACT_DSFR_MODULE_TO_DSFR_COMPONENTSthat includes transitive dependencies (e.g.Header→ header, navigation, modal, logo, button, link, search, input, form). The table was built by extracting thefr-*classes each component (and its internal imports) renders, and mapping them to the owning DSFR stylesheet. When in doubt, a dependency is included (too much CSS is a size cost, not enough is a rendering bug).fr-table→ table,fr-btn→ button, ...) catchesfr.cx("fr-table")/ plain JSX class usage without the React component. Stylesheets are deliberately not scanned: this detection is substring based, so a single compiled bundle (a leftoverout/, a dependency shipping the DSFR) would mark every component as used.Fail-safe: if the sources import a react-dsfr module the static table does not know (e.g. a component added in a newer release), the script warns and includes every component — output equivalent to the original bundle, never a broken page. Since that fallback exits
0and looks like a success in CI,--strictturns it into a failure instead.What is tested
test/runtime/scripts/onlyIncludeUsedComponents/): import detection (default/named/deep/require/dynamic imports,blocks/, directdsfr/component/*css imports, and negative cases for urls and comments mentioning the package), module resolution (components, non-components, unknown → fail-safe), raw class detection, asset extraction, stylesheet generation (cascade order, exclusion, url rewriting, charset/sourcemap stripping, Mui patch,main→plain css fallback for components likedownload, determinism).DSFR_COMPONENTS_CASCADE_ORDERis asserted against the section order extracted from thesourcesof the installed@gouvfr/dsfr/dist/dsfr.main.css.map, so it cannot silently drift on a DSFR bump.yarn buildOK, eslint + prettier clean.Button,Alert,Accordion+"additionalComponents": ["table"]:dsfr.min.css: 600kB → 282kB raw (-53%), 76kB → 36kB gzip (-52%) with 5/45 components. Most of the remainder is the core (typography, grid, color tokens, Marianne font-faces), which is incompressible without breaking things.fr-header,fr-footer,fr-tabs...) absent from the output, core/scheme/print/fr-grid-rowpresent, nourl("../...")left, Mui patch applied twice (hover + active).No change since last run, and a run after a fail-safe run correctly restores the trimmed output.public/dsfr/dsfr.min.csspatched,index.htmlhref gets?hash=<fnv1a>, and every asset the regenerated stylesheet references is present on disk (0 missing out of 38 urls after growing the component set fromButtontoHeader).Usage
npx react-dsfr only-include-used-components # or the standalone bin npx only-include-used-componentsTypically as a
prebuild/predevstep, next toupdate-icons. In SPA setups runcopy-static-assetsbefore it:Documented in
src/bin/README.md, which now covers the three bin scripts.Fail-safe hardening (follow-up commits)
Two paths of
resolveModuleIdToDsfrComponentsused to return[]("not a component") instead ofundefined("unknown, include everything"): a directdsfr/component/<x>stylesheet import for an<x>unknown toDSFR_COMPONENTS_CASCADE_ORDER, and any unrecognized lowercase-starting module id. Both are nowundefined.In the same vein,
linkandsharedwere listed as non-component modules while they do render DSFR markup (src/link.tsxrendersfr-link,src/shared/Fieldset.tsxrendersfr-fieldset,fr-label,fr-hint-text,fr-radio-rich), so they resolved to[]and suppressed the fail-safe rather than triggering it. They are now mapped to the components they render.Known limitations
--strict).only-include-used-icons, detection is textual: dynamically composed import paths or class names are not seen — that is whatadditionalComponentsis for.dsfr.min.cssconcatenates the upstream-minified granular files instead of re-minifying the whole bundle with thecsspackage, so its formatting differs slightly from the original (semantically identical).utility/colorsandutility/iconsare not part ofdsfr.cssupstream and are left untouched (icons are already handled byonly-include-used-icons).public/dsfrexists but noindex.htmlcan be found (a monorepo run with--projectDir, a project that lost itsindex.html), the script crashes on a message-lessAssertionError. This is pre-existing and identical inonly-include-used-icons; I'll send it as a separate PR since the fix touches both scripts.Happy to iterate on naming, the config location, or to add documentation to the website if the approach suits you.