Skip to content

feat(core): Add only-include-used-components: opt-in trimming of unused DSFR component CSS - #505

Merged
kevbarns merged 7 commits into
codegouvfr:mainfrom
kevbarns:feat/only-include-used-components
Aug 19, 2026
Merged

feat(core): Add only-include-used-components: opt-in trimming of unused DSFR component CSS#505
kevbarns merged 7 commits into
codegouvfr:mainfrom
kevbarns:feat/only-include-used-components

Conversation

@kevbarns

@kevbarns kevbarns commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Add only-include-used-components: opt-in trimming of unused DSFR component CSS

Closes #304 (or at least addresses its main pain point)

Problem

dsfr.min.css weighs ~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 after only-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.css and dsfr/dsfr.min.css in node_modules (and public/dsfr/dsfr.min.css + index.html hash busting for SPAs) by concatenating the granular stylesheets already shipped in the package (dsfr/core/*, dsfr/scheme/*, dsfr/component/<name>/*, including the print variants):

  • Whole components are included or excluded, never individual rules. A component's stylesheet is kept in full, so everything the DSFR JS can toggle at runtime keeps working.
  • Core and scheme (dark mode palette) are always included.
  • The upstream section order is preserved (constant 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.
  • Relative url(...) asset paths are rewritten from the granular file location to the dsfr/ root, so fonts and icons keep resolving, and the assets the regenerated stylesheet references are copied into public/dsfr (SPA setups), the way only-include-used-icons does for icons.
  • The Mui compat patch (:not([class^="Mui"]) on button:not(:disabled):hover/active, cf scripts/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 variants covers 100% of the rules of dsfr.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

  1. Imports: an @codegouvfr/react-dsfr/<Module> specifier in an actual import statement (from "...", import "...", import("..."), require("..."), @import "..."), resolved through a static table REACT_DSFR_MODULE_TO_DSFR_COMPONENTS that includes transitive dependencies (e.g. Header → header, navigation, modal, logo, button, link, search, input, form). The table was built by extracting the fr-* 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).
  2. Raw class names: a small static table of root class prefixes per component (fr-table → table, fr-btn → button, ...) catches fr.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 leftover out/, a dependency shipping the DSFR) would mark every component as used.
  3. Config escape hatch for anything the detection cannot see (classes built dynamically, CMS content, components only referenced from a stylesheet...):
    // package.json
    "react-dsfr": {
        "additionalComponents": ["table", "Range"]
    }
    (accepts DSFR CSS component names or react-dsfr component names)

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 0 and looks like a success in CI, --strict turns it into a failure instead.

What is tested

  • 28 unit tests (test/runtime/scripts/onlyIncludeUsedComponents/): import detection (default/named/deep/require/dynamic imports, blocks/, direct dsfr/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 like download, determinism).
  • DSFR_COMPONENTS_CASCADE_ORDER is asserted against the section order extracted from the sources of the installed @gouvfr/dsfr/dist/dsfr.main.css.map, so it cannot silently drift on a DSFR bump.
  • Full suite passes: 25 files / 103 tests, yarn build OK, eslint + prettier clean.
  • Manual integration test on a throwaway Vite-like project importing 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.
    • excluded components (fr-header, fr-footer, fr-tabs...) absent from the output, core/scheme/print/fr-grid-row present, no url("../...") left, Mui patch applied twice (hover + active).
    • idempotent: second run prints No change since last run, and a run after a fail-safe run correctly restores the trimmed output.
    • SPA path: public/dsfr/dsfr.min.css patched, index.html href 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 from Button to Header).

Usage

npx react-dsfr only-include-used-components
# or the standalone bin
npx only-include-used-components

Typically as a prebuild/predev step, next to update-icons. In SPA setups run copy-static-assets before it:

"scripts": {
    "predev": "react-dsfr copy-static-assets && react-dsfr update-icons && react-dsfr only-include-used-components",
    "prebuild": "react-dsfr copy-static-assets && react-dsfr update-icons && react-dsfr only-include-used-components --strict"
}

Documented in src/bin/README.md, which now covers the three bin scripts.

Fail-safe hardening (follow-up commits)

Two paths of resolveModuleIdToDsfrComponents used to return [] ("not a component") instead of undefined ("unknown, include everything"): a direct dsfr/component/<x> stylesheet import for an <x> unknown to DSFR_COMPONENTS_CASCADE_ORDER, and any unrecognized lowercase-starting module id. Both are now undefined.

In the same vein, link and shared were listed as non-component modules while they do render DSFR markup (src/link.tsx renders fr-link, src/shared/Fieldset.tsx renders fr-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

  • The two static tables must be maintained when components are added (the fail-safe makes forgetting harmless: the CSS just stops being trimmed for projects using the new component, with a console warning inviting to report it, or a hard failure under --strict).
  • Like only-include-used-icons, detection is textual: dynamically composed import paths or class names are not seen — that is what additionalComponents is for.
  • The trimmed dsfr.min.css concatenates the upstream-minified granular files instead of re-minifying the whole bundle with the css package, so its formatting differs slightly from the original (semantically identical).
  • utility/colors and utility/icons are not part of dsfr.css upstream and are left untouched (icons are already handled by only-include-used-icons).
  • If public/dsfr exists but no index.html can be found (a monorepo run with --projectDir, a project that lost its index.html), the script crashes on a message-less AssertionError. This is pre-existing and identical in only-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.

… 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.
Copilot AI lite review requested due to automatic review settings August 14, 2026 15:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts plus CLI wiring via react-dsfr and a dedicated bin entry.
  • Implement component usage detection (react-dsfr imports, raw fr-* class prefixes, and package.json escape hatch) and rebuild dsfr.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.

@kevbarns kevbarns changed the title Add only-include-used-components: opt-in trimming of unused DSFR component CSS feat(core): Add only-include-used-components: opt-in trimming of unused DSFR component CSS Aug 14, 2026

@lsagetlethias lsagetlethias left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 against dsfr.main.css + dsfr.print.css (the correct target, since scripts/build/build.ts:58 already overwrites dsfr/dsfr.css with 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|active occurs twice, both in core, 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:

  1. transcription is at the wrong index in DSFR_COMPONENTS_CASCADE_ORDER (line 83). Upstream section banners in dsfr.main.css read ... TABLE TRANSCRIPTION HEADER, so it belongs between line 98 and line 99, not right after content. 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 installed dsfr.main.css would stop it drifting on the next DSFR bump, and would be cheap given the banners are machine-readable.

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

  3. shared (line 242) and link (line 240) are in NON_COMPONENT_MODULE_IDS but do render DSFR markup. src/shared/Fieldset.tsx emits fr-fieldset, fr-fieldset__legend, fr-label, fr-hint-text (form) plus fr-radio-rich, fr-radio-rich__img (radio), and src/link.tsx:40,69 renders fr-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. Mapping shared to ["form", "radio", "checkbox"] and link to ["link"] would close it.

  4. additionalComponents entries mapping to [] report the opposite of what happens (lines 881-900). "Chart" (line 124) is the only one today: the run prints Including 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).

  5. fs.readdirSync on dsfr/component (line 790) has no guard and dumps a raw ENOENT ... scandir stack. The nominal case is fine since the published package ships the folder, but a workspace-linked or pruned node_modules gets an unreadable error. An existsSync + assert(false, "...") in the same register as line 485 would be consistent with the rest of the script.

  6. 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 a prebuild step for everyone:

    • assert(htmlFilePath !== undefined) (line 769) throws a message-less AssertionError as soon as public/dsfr exists without an index.html. That is every Next.js project that has run copy-static-assets. I reproduced it, and reproduced the identical crash with only-include-used-icons. Making htmlFilePath optional in spaParams and skipping addHashQueryParameterInIndexHtml would let those projects benefit from the trim instead of crashing.
    • The hasChanged early return (line 986) also skips the index.html hash 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.
  7. Documentation. src/bin/README.md is a single line and nothing there covers this script; additionalComponents is 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 with copy-static-assets would go a long way.

Happy to re-review once the two inline points are addressed.

Lilian & Claude 🤖

Comment thread src/bin/only-include-used-components.ts
Comment thread src/bin/only-include-used-components.ts Outdated
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.
@kevbarns

kevbarns commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

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. copyUsedDsfrAssetsToStatic() copies the non-data: url() of the regenerated stylesheet from node_modules into public/dsfr, mirroring copyUsedDsfrIconsToStatic(). I chose the targeted copy over deleting version.txt so the script stays self-sufficient. It runs outside the hasChanged early return — inside it, a purged public/dsfr would stay broken as long as the CSS didn't change, the same class of problem as your 6b. Replaying your repro: public/dsfr goes from 12 to 19 svg, 0 of the 38 urls missing on disk.

Inline 2 — fail-safe surface. Took the simpler of your two options: css/scss/sass/less are gone, the list is now identical to only-include-used-icons.ts:390. Module ids are read from actual import specifiers only (from, import, import(), require, @import); your two URLs are covered by a negative test, plus a case where a real import sits next to such a mention. You're also right that a warning nobody reads plus exit 0 is not a remedy: --strict now exits 1 before writing anything. Verified — your .mdx URL and a DSFR bundle dropped in out/assets/index.css both leave the output at 9/45 components.

1 — transcription. Moved, and asserted against the installed DSFR. One correction on the justification: dsfr.main.css has no component section banners, only /*! media sm */. What is machine-readable is dsfr.main.css.map — the first occurrence of each component/<name>/main.scss in its sources. That confirms your placement, and also that the rest of the array is exact, including link, whose sources appear early only as style/tool/* mixins inlined into accordion. radio has no main.scss upstream, so the test falls back to its first stylesheet, which lands it between notice and card as the array already had it. Checked the test isn't vacuous: restoring the old position fails it on that exact entry.

2 — reworded in the description, code unchanged, keeping your finding that the reordering is inert.

3shared["form", "radio", "checkbox"], link["link"], both out of NON_COMPONENT_MODULE_IDS.

4Chart now reports that it maps to no DSFR stylesheet and that its CSS comes from @gouvfr/dsfr-chart. Also added a warning when the react-dsfr entry exists without an additionalComponents key — a typo there disabled the escape hatch with no output at all.

5existsSync + assert(false, ...), same register as the existing one.

6 — agreed on both. They're going into a separate PR, opened right after this one, since the fix has to touch only-include-used-icons too and that is outside this PR's scope. Worth noting the scope is narrower than "every Next.js project that has run copy-static-assets": that command asserts "Can't locate your index.html file." before creating anything, so a Next.js project never gets a public/dsfr through it. What does reproduce the crash is public/dsfr existing while no index.html is findable — a monorepo run with --projectDir, or a project that lost its index.html.

7src/bin/README.md now covers the three bin scripts: usage, prebuild integration, the ordering constraint with copy-static-assets and why it exists, additionalComponents, and --strict. Left the website alone since it lives in another repo — happy to send a page there if you tell me where it fits.


Edit: corrected the last sentence of point 6 — I had claimed only-include-used-icons is unusable in Next.js, which isn't the case.

@lsagetlethias lsagetlethias left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Re-checked everything by running it, not by reading:

  • Assets: replayed the repro, public/dsfr goes 12 -> 19 svg, menu-fill.svg present, 0 of 38 urls missing on disk. Same numbers you report.
  • Fail-safe: the two URLs in an .mdx and a DSFR bundle dropped in out/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). --strict exits 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, radio included. Your test is not vacuous either, moving transcription back 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.
@kevbarns

Copy link
Copy Markdown
Collaborator Author

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 *\. And your point about why the map is still the better source is the one worth recording: the banners are a defective index (44 for 45 components, badge/consent/notice/radio have none, notice labelled ALERT so alert appears twice), where the map only misses radio. That is now what the test docblock says, instead of denying the banners exist.

Green skip. Fixed, and you're right that it's the same shape as the thing I had just added --strict for. It now asserts the map is present before reading it. Checked both ways: passes normally, fails with dsfr.main.css.map not found when I move the file away.

While I was in there I also applied the #506 fix to this script, since only-include-used-components.ts doesn't exist on main and couldn't ship in that PR: spaParams.htmlFilePath is optional, and the cache busting rewrite moved out of the hasChanged guard. Verified the 6b scenario end to end — reverting the hash by hand while the CSS is unchanged now gets repaired on the next run, where it previously couldn't.

#506 is open for the only-include-used-icons half.

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

Copy link
Copy Markdown
Collaborator Author

Two small follow-ups pushed here as a consequence of @lsagetlethias's review of #506, both on the same htmlFilePath comment in only-include-used-components.ts, no behaviour change:

  • 8ac9a56 — the comment claimed htmlFilePath is undefined in Next.js because public/dsfr exists. That is wrong, and it had been copied into this PR from fix(bin): Don't crash only-include-used-icons when public/dsfr exists without an index.html #506. next-appdir and next-pagesdir run the trimming scripts without copy-dsfr-to-public, so there is no public/dsfr there at all, spaParams is undefined, and the field is never reached.
  • a84376f — dropped the replacement's claim that copy-static-assets can produce this state. It cannot: copy-dsfr-to-public.ts:60 asserts "Can't locate your index.html file." before the mkdirSync(dsfrDirPath) at :95, so a project with no index.html never gets a public/dsfr out of it. This is consistent with point 6 of my earlier reply above.

For the record, the two substantive findings of that review do not apply here, checked explicitly:

  • the hasChanged guard already has the right shape in this script — only clearCache sits behind it, the asset copies are ahead of it;
  • ordering is deterministic here, the component set is consumed through availableDsfrComponents.filter(...) rather than through the Set's insertion order, so the generated stylesheet is byte stable across runs. only-include-used-icons was not, and that is fixed in fix(bin): Don't crash only-include-used-icons when public/dsfr exists without an index.html #506.

103 tests, eslint and prettier clean.

@garronej

Copy link
Copy Markdown
Collaborator

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 react-dsfr@1.34.0.

What changed

The new canonical command is:

npx react-dsfr optimize-css

It sequentially:

  1. Optimizes the icon stylesheet (only include used icons).
  2. Rebuilds the DSFR stylesheet with only the CSS of the components used by the application.

The internal implementation was renamed from only-include-used-components.ts to the more explicit only-include-css-of-used-components.ts.

The following APIs were removed:

npx react-dsfr only-include-used-components
npx only-include-used-components

update-icons and its historical standalone executable remain available for backward compatibility.

copy-static-assets and the legacy copy-dsfr-to-public executable are unchanged.

Rationale

I want react-dsfr to expose one CLI which routes to the supported workflows, instead of publishing a new standalone executable for each operation.

only-include-used-components was also ambiguous: it did not say that it was optimizing CSS, and it exposed an implementation step that users had to run on top of updating the icon stylesheet.

optimize-css describes the intended outcome and guarantees that icon and component optimization happen together, in the correct order.

Breaking change and migration

This only affects projects that adopted the new component command introduced in 1.33.x.

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"
    }
}

--projectDir, --silent, --strict, and the react-dsfr.additionalComponents escape hatch remain supported.

Documentation and validation

The 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 react-dsfr@1.34.0, their lockfiles regenerated, and their builds verified:

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.

kevbarns pushed a commit that referenced this pull request Aug 31, 2026
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.
@kevbarns

kevbarns commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Hello @garronej, thank you for your feedback and no problem, happy to help out here.

optimize-css is a better entry point, and routing everything through a single CLI is the right call. Nothing in the rename touches the approach.

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 (fr-m*-* / fr-p*-*) lives inside core.*.css, so there is no file to drop. #509 removes the unused spacing rules from core itself, opt-in behind --trim-spacing-utilities.

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 --strict exits non-zero. Escape hatch mirrors additionalComponents (additionalSpacingUtilities).

The PR is currently conflicting with main after the 1.34.0 rework — I'll rebase it onto only-include-css-of-used-components.ts. One API question first, since it's the same concern you just addressed: do you want the flag exposed as npx react-dsfr optimize-css --trim-spacing-utilities, or as a react-dsfr.trimSpacingUtilities entry in package.json, next to additionalComponents? I'll follow whichever fits your direction and adapt the docs.

Separate question worth settling now: opt-in indefinitely, or default-on in a future major?

@kevbarns

Copy link
Copy Markdown
Collaborator Author

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

main was merged into feat/trim-spacing-utilities. Git resolved the rename on its own, so the only manual work was three references the rename could not reach:

  • the import in generateDsfrCssCode.test.ts, where both sides had edited the same lines,
  • npx react-dsfr only-include-used-components --trim-spacing-utilities in src/bin/README.md,
  • the header of src/bin/trimSpacingUtilities.ts, which named the removed command.

The diff between the merge result and main is exactly the delta of #509 (12 files, +1974/-71), so nothing from the 1.34.0 rework was reverted along the way.

One thing I checked rather than assumed: optimize-css passes the same args to the icon stage first, and only-include-used-icons.ts runs them through yargsParser without rejecting unknown flags, so --trim-spacing-utilities is inert there. No guard needed.

Numbers, re-measured after the rework

Same Vite-shaped app (Button, fr-mt-4w, fr-p-2v), running the built bin through npx react-dsfr optimize-css:

raw gzip
optimize-css 231 908 B 31 839 B
optimize-css --trim-spacing-utilities 154 430 B 20 394 B

77.5 kB raw, 11.4 kB gzip, unchanged by the API rework. 1209 of 1215 spacing rules removed. A second run reports No change since last run, and --strict exits 0.

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, dsfr.css (276 127 B) and dsfr.min.css (231 908 B) are byte for byte what main at 1.34.0 produces on the same project. I rebuilt main in a separate worktree and compared, rather than reasoning from the diff.

Green: 156 tests, tsc -p src/bin (14/14 files, the renamed one included), format:check, lint:check. The build-time cross-check against the real CSS parser passes too, which is what actually gates the rule-level surgery.

Still open

The flag currently stays a CLI flag, optimize-css --trim-spacing-utilities, because that was the only choice that did not rewrite the feature while the question was open. Two arguments for the package.json side, now that I have looked at it more closely:

  • additionalSpacingUtilities already lives in package.json, so today the configuration of this one feature is split across two places.
  • a CLI flag on optimize-css reaches both stages, and neither validates unknown flags. It works, but it relies on that.

Against it: --strict and --silent are CLI flags, and --trim-spacing-utilities changes what the command does rather than describing the project, which is the same category. I lean CLI flag for that reason, but you own the API and I will move it in one commit if you prefer the package.json entry.

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?

kevbarns added a commit that referenced this pull request Aug 31, 2026
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.
habbichelotfi pushed a commit to habbichelotfi/react-dsfr that referenced this pull request Aug 31, 2026
@garronej

Copy link
Copy Markdown
Collaborator

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 react-dsfr for each major project type. In 2026, our primary targets are Vite SPAs and the Next.js App Router; CRA and the Next.js Pages Router remain secondary compatibility concerns.

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:

Is this a genuine preference, or am I introducing a knob because I’m not confident the default is robust enough?

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 setup

Icon 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"
    }
}

optimize-css should work without configuration for standard Vite and Next.js App Router projects.

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 hatches

I 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:

  • What does “additional” mean?
  • Why does the user need to declare these values?
  • If a table is missing its styles, should they write Table or table?
  • Does case matter?
  • Are wildcards supported, and what is their exact syntax?
  • How is the correct package.json resolved in a monorepo?

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:

The CSS for Table could not be detected statically. Add the following entry to your package.json: …

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:

  1. One zero-configuration recommended setup that works for approximately 98–99% of projects.
  2. react-dsfr optimize-css as that canonical setup.
  3. One clear fallback, react-dsfr update-icons, for projects outside the build setups we support.
  4. No collection of independent optimization flags or cryptic lists that users must tune until their application looks correct.

The complexity required to make this reliable should live inside react-dsfr, not in every application that consumes it.

@kevbarns

Copy link
Copy Markdown
Collaborator Author

Hello @garronej, and thanks for the clear direction.

#509 is rebased on main and CI is green, so it is ready for whatever shape we land on. My earlier message said the branch was merged, it is a rebase now, two commits.

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 (ts, tsx, js, jsx, mdx, html, htm, svelte, vue), so a file exporting "fr-mt-8w fr-pb-12v fr-callout" is picked up: 1207 of 1215 spacing rules trimmed, the callout CSS back in the output, --strict exit 0. It covers components too. So additionalSpacingUtilities can be a README paragraph instead of permanent API.

With the marker file covering the escape hatch, that field's only remaining job is silencing --strict. A detected dynamic prefix over-includes rather than degrades (`fr-mb-${v}` keeps all 198 fr-mb-* and still trims 1074 of 1215), yet --strict fails on it today. That conflation is my mistake. Once --strict fails only on real degradation (missing or malformed manifest, core hash mismatch), the field has no purpose left and I delete it.

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 fr-callout and fr-mt-8w in a .scss both disappear from the output and --strict still exits 0, while the same two class names in a .ts are kept. Stylesheets are excluded because component detection matches substrings, and a compiled bundle would mark everything as used. Spacing tokens have a strict grammar and over-inclusion only costs bytes, so I would scan .css and .scss for spacing tokens only. I tested the worst case by dropping a full copy of core.main.min.css into a scanned source file: Trimmed 0 of 1215, output untrimmed, no error. It degenerates in the safe direction.

That closes the blind spot I can close. Classes assembled at runtime from CMS or API content stay out of reach, and update-icons is the honest answer for those projects.

So, two questions:

  1. Is that stylesheet scan acceptable? With it, I am comfortable with default-on, no flag and no config field. Without it, I would rather not ship the trimming than ship a silent regression.
  2. Is additionalComponents in scope here, or do you want it handled separately since it is already released?

Once you confirm, I will rework #509 accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CSS splitting

5 participants