Skip to content

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

Open
kevbarns wants to merge 3 commits into
codegouvfr:mainfrom
kevbarns:feat/only-include-used-components
Open

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

Conversation

@kevbarns

Copy link
Copy Markdown

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 cascade order is preserved (constant DSFR_COMPONENTS_CASCADE_ORDER, determined empirically from the section order of dsfr.main.css).
  • Relative url(...) asset paths are rewritten from the granular file location to the dsfr/ root, so fonts and icons keep resolving (copy-dsfr-to-public picks up the correct asset subset when it runs after this script).
  • 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: any @codegouvfr/react-dsfr/<Module> occurrence in the sources (same crawling as only-include-used-icons, extended to .css/.scss/.sass/.less files), 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.
  3. Config escape hatch for anything the detection cannot see (classes built dynamically, CMS content...):
    // 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.

What is tested

  • 19 new unit tests (test/runtime/scripts/onlyIncludeUsedComponents/): import detection (default/named/deep/require/dynamic imports, blocks/, direct dsfr/component/* css imports), module resolution (components, non-components, unknown → fail-safe), raw class detection, stylesheet generation (cascade order, exclusion, url rewriting, charset/sourcemap stripping, Mui patch, main→plain css fallback for components like download, determinism).
  • Full suite passes: 24 files / 94 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>.

Follow-up fix

While re-reading the fail-safe logic in resolveModuleIdToDsfrComponents, found that two paths returned [] (silently "not a component") instead of undefined (triggers the warn-and-include-everything fail-safe): 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, so a future DSFR/react-dsfr release that adds a component this script doesn't know about degrades to "include everything" with a warning, instead of silently shipping incomplete CSS.

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

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:

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

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

2 participants