Skip to content

feat(core): only-include-used-components: opt-in trimming of the spacing utility grid (~78 kB raw / ~11.5 kB gzip) - #509

Open
kevbarns wants to merge 2 commits into
mainfrom
feat/trim-spacing-utilities
Open

feat(core): only-include-used-components: opt-in trimming of the spacing utility grid (~78 kB raw / ~11.5 kB gzip)#509
kevbarns wants to merge 2 commits into
mainfrom
feat/trim-spacing-utilities

Conversation

@kevbarns

Copy link
Copy Markdown
Collaborator

Closes #508. Same architecture and fail-safe philosophy as #505/#506.

What this does

--trim-spacing-utilities, a separate opt-in of only-include-used-components, removes from the core stylesheet the fr-m*-* / fr-p*-* utilities the project's sources never use. Measured on a Vite-shaped app importing Button and using two utilities (fr-mt-4w, fr-p-2v):

raw gzip
component trimming only 231.9 kB 31.8 kB
+ --trim-spacing-utilities 154.4 kB 20.4 kB

1209 of the 1215 spacing rules are removed (the used tokens, their grouped aliases, and the 4 utilities react-dsfr's own components render are kept). Without the flag the output is byte identical to the current release (verified by rebuilding main and comparing).

How it stays safe

This is the script's first rule level surgery (the spacing grid lives inside core.*.css, there is no granular file to include or exclude), so the invariant of #505 does not apply and the risky part is moved to react-dsfr's build time, where the css parser (a devDependency, unavailable in the consumer-side bin) exists:

  • scripts/build/generateSpacingUtilitiesManifest.ts cross-checks the string level extractor against the real parser on the exact files that get published — grammar exhaustiveness (a wider regex net must not catch anything the strict grammar misses), zero mixed rules, rule text uniqueness, identical token sets across the 4 core variants. Any violated assumption is a build failure, so a @gouvfr/dsfr bump cannot drift silently. The same asserts run in CI against the installed DSFR (spacingUtilitiesManifest.test.ts, hard failure when node_modules is absent, no skip).
  • The validated files are fingerprinted in a generated dsfr/core/spacing-utilities.json. At run time, trimming only happens when the core file's hash matches; on any mismatch (patched file, drift) the stylesheet ships untrimmed with a never-silenced warning, and --strict exits non zero.
  • Removal is by offset splicing: kept rules stay exactly where they were, the cascade is untouched, and "everything used" produces a byte identical output.
  • The manifest also carries the utilities react-dsfr's own components render (fr-mt-1v, fr-mb-2v/4v/6v today), derived at build time from src/ — the crawl deliberately excludes the package, and this list is generated, never written by hand.

Dynamic class names

One deviation from the issue text: instead of falling back to the whole family, a dynamically constructed class (`fr-mt-${x}w`, "fr-m" + side) keeps every utility its static prefix can produce. Same fail-safe guarantee, strictly finer: `fr-m${x}` keeps the margins but still trims the paddings, a bare `fr-${x}` degenerates into keeping the grid, and `fr-icon-${name}` or `fr-modal-title-${id}` trigger nothing because the prefixes are tested against the actual token set extracted from the installed CSS. Each uncovered dynamic construction is a never-silenced warning naming the file and the remedy, and fails --strict.

The escape hatch mirrors additionalComponents:

"react-dsfr": {
    "additionalSpacingUtilities": ["fr-mt-2w", "fr-mb-*"]
}

A * suffix declares a prefix and acknowledges the dynamic constructions it covers (that is how --strict passes when the dynamic class is intended). Unknown values warn and disable spacing trimming for the run.

Tests

The false positive cases were written first, per dimension: mentions in urls/comments (counted on purpose, and pinned as such), maximal-run near misses (fr-mt-2vfoo), dynamic near misses (fr-modal-title-${id}), grouped selectors kept whole when one token is used, exact partition (trimmed output + removed rules reassemble the input byte for byte), hash/count mismatch fail-safes. The build time cross-check was verified non vacuous by re-introducing a grammar bug (dropping negative value support) and a scanner bug, both fail loudly.

End to end, on a throwaway project exercising the shipped bin: nominal trim, --strict in both directions, escape hatch coverage, one-byte core corruption (warn + untrimmed output), idempotency (No change since last run), and --silent not hiding any degradation warning.

…ing utility grid

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 script'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.
Copilot AI lite review requested due to automatic review settings August 21, 2026 14:42
@kevbarns kevbarns self-assigned this Aug 21, 2026

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

Adds an opt-in (--trim-spacing-utilities) to only-include-used-components that trims unused DSFR spacing utility rules (fr-m* / fr-p*) from the core stylesheet, using a build-time generated manifest + runtime hash-based fail-safes to avoid unsafe rule-level purging.

Changes:

  • Introduces runtime extraction/detection/trimming utilities for spacing rules, including fail-safe “do nothing” behavior on manifest/hash/count mismatches.
  • Adds a build-time generator that validates the string-level extractor against a real CSS parser and emits dsfr/core/spacing-utilities.json.
  • Updates the CLI + documentation and adds a comprehensive test suite for trimming behavior, detection edge cases, and manifest invariants.

Reviewed changes

Copilot reviewed 8 out of 11 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/bin/trimSpacingUtilities.ts Implements spacing rule extraction, used token detection, dynamic-prefix detection, and core CSS trimming with fail-safes.
src/bin/only-include-used-components.ts Wires --trim-spacing-utilities into the CLI flow, config escape hatch parsing, warnings, and --strict behavior.
scripts/build/generateSpacingUtilitiesManifest.ts Build-time manifest generator that cross-checks extraction vs a CSS parser and fingerprints core variants.
scripts/build/build.ts Integrates manifest generation into the build output under dsfr/core/spacing-utilities.json.
src/bin/README.md Documents spacing trimming behavior, safety model, and additionalSpacingUtilities escape hatch.
test/runtime/scripts/onlyIncludeUsedComponents/trimSpacingUtilitiesFromCoreCss.test.ts Unit tests for trim behavior, partitioning correctness, and fail-safe paths.
test/runtime/scripts/onlyIncludeUsedComponents/spacingUtilitiesManifest.test.ts Tests manifest generator assumptions against installed DSFR and pins expected counts.
test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts Ensures trimming integrates correctly with DSFR CSS generation and transformation ordering.
test/runtime/scripts/onlyIncludeUsedComponents/extractSpacingCssRules.test.ts Tests extraction correctness and safety against false-positive rule shapes.
test/runtime/scripts/onlyIncludeUsedComponents/detectUsedSpacingTokens.test.ts Tests literal token detection behavior, including intended over-inclusion cases.
test/runtime/scripts/onlyIncludeUsedComponents/detectDynamicSpacingClassPrefixes.test.ts Tests dynamic construction prefix detection and near-miss behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

A corrupted spacing-utilities.json (invalid JSON or unexpected shape) used to
surface as a raw stack trace, unlike the corrupted-core case which warns and
ships untrimmed. parseSpacingUtilitiesManifest now shape-checks the file and a
malformed manifest takes the same path as a missing one: never-silenced
warning, untrimmed stylesheet, non zero exit under --strict.

Also log that "additionalSpacingUtilities" has no effect when the flag is
not passed (a forgotten flag made the configuration a silent no-op), and
document in the README exactly which two dynamic class construction forms are
detected (template literal interpolation and + concatenation).
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.

only-include-used-components: opt-in trimming of the spacing utility grid in core (~50-60 kB raw)

2 participants