Skip to content

fix(bin): Don't crash only-include-used-icons when public/dsfr exists without an index.html - #506

Merged
kevbarns merged 5 commits into
codegouvfr:mainfrom
kevbarns:fix/skip-index-html-when-absent
Aug 19, 2026
Merged

fix(bin): Don't crash only-include-used-icons when public/dsfr exists without an index.html#506
kevbarns merged 5 commits into
codegouvfr:mainfrom
kevbarns:fix/skip-index-html-when-absent

Conversation

@kevbarns

Copy link
Copy Markdown
Collaborator

Don't crash only-include-used-icons when public/dsfr exists without an index.html

Spun off from the review of #505 (points 6a and 6b of @lsagetlethias), since the fix belongs to only-include-used-icons and is unrelated to that PR.

Problem

getCommandContext builds spaParams as soon as public/dsfr exists, and asserts an index.html alongside it:

https://github.com/codegouvfr/react-dsfr/blob/main/src/bin/only-include-used-icons.ts#L404

The assertion has no message, so when it fires the user gets a bare AssertionError: Wrong assertion encountered and no indication of what to do.

It fires whenever public/dsfr exists while neither <projectDir>/index.html (Vite) nor <publicDir>/index.html (CRA) does. Two setups I could reproduce:

  • a monorepo invoked as only-include-used-icons --projectDir apps/web from the workspace root, where the html file is not where either branch looks;
  • a project that used to be a Vite/CRA app and no longer has its index.html.

Nothing else in the script needs the html file — it is only used to append a ?hash= cache busting query parameter — so the run has no reason to abort.

Changes

  • spaParams.htmlFilePath becomes string | undefined, and addHashQueryParameterInIndexHtml returns early when there is nothing to rewrite. The icons are still trimmed and still copied to public/dsfr.
  • That rewrite moves out of the hasChanged early return. It is idempotent, and inside the guard a stale or hand-reverted hash could never be repaired as long as icons.min.css itself did not change. It now also skips the write when the html is unchanged, so no needless mtime bump.

Verification

Monorepo repro above, public/dsfr/utility/icons populated, no index.html:

  • before: AssertionError: Wrong assertion encountered, nothing written;
  • after: exits 0, public/dsfr/utility/icons/icons.min.css trimmed to the 6 used icons, second run prints No change since last run.

Full suite passes (75 tests), yarn build, eslint and prettier clean.

`assert(htmlFilePath !== undefined)` threw a message-less AssertionError as soon
as public/dsfr existed while neither <projectDir>/index.html nor
<publicDir>/index.html did. A monorepo invoked with --projectDir reproduces it,
as does a project that lost its index.html. Nothing else in the script needs the
html file, so make `spaParams.htmlFilePath` optional and skip only the cache
busting query parameter when it is missing.

Move that rewrite outside of the `hasChanged` early return while at it. It is
idempotent, and inside the guard a stale or hand reverted hash could never be
repaired as long as icons.min.css itself did not change.
Copilot AI lite review requested due to automatic review settings August 19, 2026 11:21

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.

Copilot wasn't able to review any files in this pull request.


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

kevbarns added a commit to kevbarns/react-dsfr that referenced this pull request Aug 19, 2026
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.
spaParams:
| {
dsfrDirPath_static: string;
htmlFilePath: string;

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.

This is wrong, and it is my fault for starting it. Next.js has no public/dsfr in the documented setup: copy-dsfr-to-public.ts:7 says "not in Next.js for example", and this repo's own integration apps confirm it, next-appdir and next-pagesdir run only-include-used-icons alone while vite and cra run copy-dsfr-to-public && only-include-used-icons. A canonical Next.js fixture exits 0 on main too, so there is no crash there. My original repro created public/dsfr by hand, which is not the Next.js path, and you took my word for it in #505. Your PR body is accurate as written (monorepo, ex-SPA), it is just this comment and the "unusable in Next.js today" line. Accurate framing: any project with a public/dsfr and no index.html, which includes a Next.js app that opted into copy-static-assets.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right, and it's worth spelling out the evidence since it also settles part of my #505 write-up: test/integration/next-appdir/package.json and next-pagesdir/package.json run only-include-used-icons alone, while vite and cra run copy-dsfr-to-public first. So in the documented Next.js setup there is no public/dsfr, dsfrDirPath_static is undefined, spaParams is undefined, and the assertion is never reached. No crash there, as you say.

One correction to the framing you propose, though: it does not include a Next.js app that opted into copy-static-assets. That command asserts "Can't locate your index.html file." at copy-dsfr-to-public.ts:60, before the mkdirSync(dsfrDirPath) at :95, so a project with no index.html never gets a public/dsfr out of it either. Confirmed on a fixture: it exits non-zero and public/ stays empty. That is the same point I made in #505 point 6 and then contradicted in this comment.

Reworded in fa0dbe1, then corrected in f0297d7 once I checked that copy-static-assets claim, down to the states that actually reach this branch:

// Undefined whenever public/dsfr exists but no index.html was found to add
// a cache busting query parameter to: a monorepo invoked with --projectDir
// where the html is not where either branch looks, a project that used to be
// a Vite/CRA app and lost its index.html, or a public/dsfr that was committed
// or restored by other means. Not reachable through copy-static-assets: it
// asserts "Can't locate your index.html file." before creating anything.

The same comment had been copied into #505; corrected there too (a84376f).

@@ -584,33 +618,6 @@ export async function main(args: string[]) {
.map(([srcFilePath, destFilePath]) => cp(srcFilePath, destFilePath))

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.

This copy and its sibling generateUsedRemixiconFiles stayed inside the hasChanged guard while the html rewrite moved out, and your own NOTE argues for both. copy-dsfr-to-public does not copy public/dsfr/icons/arrows/arrow-down-circle-fill.svg, only this script does. Delete it, leave the CSS alone, re-run: No change since last run, and the icon stays missing until something else changes the stylesheet. Same shape as the asset bug you fixed in #505.

Unrelated nit two functions up, since it is not in the diff and I cannot anchor it: fs.mkdirSync(remixiconDirPath) has no { recursive: true }. It only fires on a public/dsfr that exists without public/dsfr/icons, which is the kind of odd state this PR is about tolerating.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both confirmed, fixed in fa0dbe1.

The guard. My own NOTE argued for it and I moved only half of it. generateUsedRemixiconFiles and copyUsedDsfrIconsToStatic now sit in the same Promise.all as the html rewrite, ahead of the guard. Only clearCache stays behind it, which is the one thing that genuinely depends on the stylesheet having changed.

The nit. fs.mkdirSync(remixiconDirPath, { "recursive": true }), and the existsSync above it is gone since it no longer guards anything.

What your repro turned up. It took several attempts to reproduce, because it only fails about half the time, and the reason is a second bug. setUsedIconClassNames is filled from a Promise.all over the source files, so its insertion order follows I/O completion order and changes between runs. The rules of icons.min.css were emitted in that order, so the file differed byte-for-byte from one run to the next for an unchanged icon set, and hasChanged flipped at random: needless CSS rewrites, needless ?hash= churn, node_modules/.next/cache wiped for nothing, and No change since last run reported only by luck. It was also masking the bug you describe half the time. Sorted in be8e28f — rule order carries no meaning here, every rule targets a distinct .fr-icon-*::before or .ri-*::before selector. Happy to split that commit into its own PR if you would rather keep this one to the review points.

Verified on a fixture (public/dsfr populated, no index.html), deleting public/dsfr/icons/arrows/arrow-down-circle-fill.svg and public/dsfr/icons/remixicon/ before each run:

before after
deleted assets repaired 4 failures / 8 runs 0 / 16
public/dsfr/icons absent → mkdirSync ENOENT recreated
No change since last run over 6 consecutive runs random 6 / 6

- Correct the `htmlFilePath` comment. Next.js has no `public/dsfr` in the
  documented setup, so it never reaches the assertion that used to fire here.
  The real trigger is any project with a `public/dsfr` and no `index.html`.
- Move `generateUsedRemixiconFiles` and `copyUsedDsfrIconsToStatic` out of the
  `hasChanged` guard alongside the html rewrite. `copy-dsfr-to-public` builds
  its keep list from the `url()` of `dsfr.min.css`, so these icons are written
  by this script alone. A deleted svg was never repaired as long as
  `icons.min.css` itself did not change.
- `fs.mkdirSync(remixiconDirPath, { recursive: true })`, so a `public/dsfr`
  without an `icons` directory no longer throws ENOENT.

Only `clearCache` stays behind the guard, which is the one thing that really
depends on the stylesheet having changed.
kevbarns added a commit to kevbarns/react-dsfr that referenced this pull request Aug 19, 2026
…able

`setUsedIconClassNames` is filled from a `Promise.all` over the source files, so
its insertion order follows I/O completion order and changes between runs. The
rules of `icons.min.css` were emitted in that order, so the file differed from
one run to the next for an unchanged set of icons.

`hasChanged` compares the generated buffer against the file on disk, so it
flipped at random: `icons.min.css` and the `?hash=` query parameter were
rewritten and `node_modules/.next/cache` was wiped on runs where nothing had
actually changed, and "No change since last run" was reported only by luck.

Rule order carries no meaning here, every rule targets a distinct
`.fr-icon-*::before` or `.ri-*::before` selector, so sorting is safe.

Found while verifying the `hasChanged` guard for the review of codegouvfr#506: the
nondeterminism was masking the missing asset bug half the time.
@kevbarns
kevbarns requested a review from lsagetlethias August 19, 2026 12:33

@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. Verified by running it: deleting public/dsfr/icons/arrows/arrow-down-circle-fill.svg and re-running without touching the CSS now prints No change since last run and restores the svg. mkdirSync recursive, only clearCache left behind the guard, 75 tests + tsc + eslint clean.

You are right about copy-static-assets and I was wrong: copy-dsfr-to-public.ts:60 asserts before the mkdirSync at :95, so it can never leave a public/dsfr without an index.html. Thanks for checking rather than taking my word for it twice.

Your sort commit is undersold. I failed to reproduce the nondeterminism twice, both times on my own broken fixtures. With a correct one (12 files, one distinct icon each, increasing sizes), six runs on the pre-sort code give five different rule orders and No change since last run never fires, six out of six. So on main this script rewrites the CSS, changes the ?hash= and wipes node_modules/.next/cache on every prebuild for any project with icons spread across several files. That is a build time regression worth naming in the commit message, not just a determinism cleanup.

Nit, non blocking: the monorepo bullet in the new comment is not an independent trigger, both scripts resolve index.html through the same two paths, so it collapses into your "committed or restored by other means" case.

@garronej

Copy link
Copy Markdown
Collaborator

Sorry my MacBook is being serviced, I'll review this once I get it back.

`setUsedIconClassNames` is filled from a `Promise.all` over the source files, so
its insertion order follows I/O completion order and changes between runs. The
rules of `icons.min.css` were emitted in that order, so the generated stylesheet
differed from one run to the next for an unchanged set of icons.

`hasChanged` compares that buffer against the file on disk, so it was true
almost every time. For any project whose icons are spread across several source
files, `icons.min.css` is rewritten, the `?hash=` query parameter in index.html
is changed, and `node_modules/.next/cache`, `.vite`, the storybook and the
babel-loader caches are wiped on every prebuild. "No change since last run" was
reported only by luck. Measured on a 40 file fixture: 15 runs, 15 different
outputs, and the message never fired once.

That makes this a build time regression, not just a determinism cleanup. Sorting
fixes it, and is safe: rule order carries no meaning here, every rule targets a
distinct `.fr-icon-*::before` or `.ri-*::before` selector.

Found while verifying the `hasChanged` guard for the review of codegouvfr#506, where the
nondeterminism was masking the missing asset bug half the time.
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.
It is not an independent trigger: both this script and copy-dsfr-to-public
resolve index.html through the same two paths, <projectDir>/index.html and
<publicDir>/index.html. If copy-static-assets was able to create public/dsfr,
an index.html was found at one of them and this script finds it too. The case
collapses into "the index.html was removed afterwards".
@kevbarns

Copy link
Copy Markdown
Collaborator Author

Thanks @lsagetlethias — both notes taken, force-pushed.

The sort commit is reworded (be8e28f37ea46f88), titled after the impact rather than the mechanism: Stop wiping the build cache on every only-include-used-icons run. The body now names what actually happens on mainicons.min.css rewritten, ?hash= changed and node_modules/.next/cache, .vite, storybook and babel-loader caches wiped on every prebuild for any project with icons across several files. Your six-run measurement matches mine on a 40 file fixture: 15 runs, 15 different outputs, No change since last run never fired once.

The monorepo case is dropped from the htmlFilePath comment (38dc8c44). You're right that it is not independent — both scripts resolve index.html through the same two paths, so if copy-static-assets could create public/dsfr then an index.html existed and this script finds it too. The comment now says that explicitly and keeps only "removed afterwards" and "restored by other means".

Follow-up, unrelated to the review: the npm publish job has been failing since npm 12 (node 20 + npm install -g npm@latestEBADENGINE), which is why 1.32.5 has a tag and a GitHub release but never reached npm. Fixed in #507 along with the 1.33.0 bump — that one should be merged after this PR so the release carries these fixes.

@kevbarns kevbarns self-assigned this Aug 19, 2026
@kevbarns
kevbarns merged commit 2c037ab into codegouvfr:main Aug 19, 2026
6 checks passed
@kevbarns
kevbarns deleted the fix/skip-index-html-when-absent branch August 19, 2026 13:28
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.

4 participants