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. |
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.