Skip to content

fix(css-extract): fix stale CSS HMR when extracted filename is hashed#14814

Open
gultyayev wants to merge 8 commits into
web-infra-dev:mainfrom
gultyayev:claude/css-hmr-contenthash-stale-mxgwm8
Open

fix(css-extract): fix stale CSS HMR when extracted filename is hashed#14814
gultyayev wants to merge 8 commits into
web-infra-dev:mainfrom
gultyayev:claude/css-hmr-contenthash-stale-mxgwm8

Conversation

@gultyayev

Copy link
Copy Markdown

Summary

With CssExtractRspackPlugin({ filename: '[name].[contenthash:8].css' }) + HMR, editing a CSS file never updated the page. Not the off-by-one described in #6869 — permanent, for every edit. Console reported success ("Reload all css"), a network request fired each cycle, and a fresh stylesheet link element was created and parsed, but the content never changed.

Root cause: __webpack_require__.miniCssF is a string literal baked into the runtime chunk at build time. HMR never re-fetches or re-evaluates the runtime chunk, so once the CSS filename's [contenthash] changed on rebuild, the extract-css HMR handler kept resolving the build-1 href forever. A second, compounding bug: even with a fresh href, the stylesheet element lookup (extractCssFindStylesheet) matched by exact href equality, which can never find the currently-installed element once its href has moved on.

Fix:

  • crates/rspack_plugin_hmr: the hot-update manifest now carries an optional miniCss map ({chunkId: freshCssFilename}), computed directly from the chunk's own emitted extract-css asset at manifest-generation time — independent of the (unreliable) miniCssF runtime function. Matched by asset ownership (ManifestAssetType::Custom("extract-css"), the same tag rspack_plugin_sri already relies on), not by filename suffix, so a chunk carrying both native CSS and extract-css output — or another plugin's own .css asset — can't have the wrong one picked. Exposed to the runtime via a new HMR_MINI_CSS_FILENAMES global (__webpack_require__.hmrMCF), only emitted when non-empty. Named miniCss (not a generic css) so this extract-css-specific channel stays distinct from any equivalent for the native CSS runtime.
  • crates/rspack_plugin_extract_css: runtime-created stylesheet link elements are tagged with a data-webpack-chunk attribute (the owning chunk id, namespaced by output.uniqueName so two independently compiled apps sharing a chunk id on one page can't steal or remove each other's stylesheet — the same identity scheme chunk/module script loading already uses). Lookups during HMR prefer matching on that instead of href. The one exception is the very first HMR update after a page load, where the element was written directly into the HTML by HtmlRspackPlugin and was never tagged — for that case only, the frozen miniCssF href is used as a lookup fallback (it still matches that element's real, unmodified href), while the manifest's fresh href is always used for the fetch itself.
  • Lazy chunks are covered too: a chunk that hasn't been imported yet has no element for the HMR handler to swap, so it just retains the fresh filename it learned about. Normal chunk loading, prefetch, and preload now all consult that retained mapping before falling back to miniCssF, so importing a lazy chunk after its CSS changed (the scenario [Bug]: live reload of styles lazy components is broken when using hashes in names #6869 itself is about) resolves the current filename instead of the stale one.
  • CssLoadingRuntimeModule::runtime_requirements() (crates/rspack_plugin_extract_css/src/runtime.rs) now also propagates the basic template's weak requirements (CSS_LOADING_BASIC_RUNTIME_REQUIREMENTS.weak, which now includes the HMR_MINI_CSS_FILENAMES read added above) alongside the create-link template's — it previously only propagated the latter. Without this, under experiments.runtimeMode: "rspack" (lexical runtime, where a weak global read compiles to a bare identifier rather than a safe __webpack_require__.x property access), loading an async extracted-CSS chunk with no HMR runtime module present would throw a ReferenceError for the undeclared hmrMiniCssFilenames binding instead of silently falling back to miniCssF. Covered by a new non-HMR runtimeMode: "rspack" config test (see below).

I deliberately left packages/rspack/src/runtime/cssExtractHmr.ts (the loader's module.hot.dispose fallback, used when runtime: false) unchanged. It already coexists with the native hmrC.miniCss handler today — proven by the pre-existing shared-stylesheet-dedup e2e test — and this fix doesn't change its behavior or introduce a new conflict between the two.

While chasing the runtimeMode: "rspack" config test above through the full configCases suite, I also found and fixed a pre-existing, unrelated false positive: css-extract/with-contenthash's "should not contain full hash runtime module" test used a plain substring check for __webpack_require__.h, which incidentally also matches any longer property name starting with the same letter — including the new hmrMCF global added here. Tightened the check to a word-boundary regex so it only matches the actual full-hash accessor.

Related links

How it was verified

  • Node-only repro (compiler.watch, no browser) confirmed the hot-update manifest now carries the correct, freshly-hashed CSS filename per rebuild.
  • e2e regression tests:
    • tests/e2e/cases/css/contenthash-hmr — hashed extract-css filename + HMR, asserts the computed background color actually updates across two consecutive edits (not just an off-by-one), and that exactly one stylesheet element remains after each update.
    • tests/e2e/cases/css/contenthash-hmr-lazy-chunk — edits a lazy chunk's CSS before its first import(), then imports it, and asserts the loaded stylesheet reflects the edit rather than the stale pre-edit filename.
  • tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk — new config test: loads an async extracted-CSS chunk under experiments.runtimeMode: "rspack" with no HMR involved at all, asserting it resolves and doesn't throw the ReferenceError described above. Verified it actually catches the regression: fails with the exact ReferenceError when the weak-requirement propagation fix is reverted, passes with it restored.
  • Full configCases suite (Config.part1-3.test.js + RuntimeModeConfig.part1-3.test.js, ~3700 tests each covering both legacy webpack and rspack lexical runtime rendering) — all passing, including the with-contenthash fix above.
  • pnpm run test:hot — 124/124 passing (hot-update manifest snapshots updated to reflect the new miniCss field; diffs reviewed — extract-css cases gained miniCss, native-CSS (type: 'css') cases correctly lost the field entirely instead of the old filename-suffix heuristic accidentally matching their asset too).
  • Full e2e suite (pnpm run test:e2e) — all passing, including the pre-existing shared-stylesheet-dedup and shared-stylesheet-dedup-link cases (no regression to the two-HMR-owners fix).
  • cargo test (rspack_core, rspack_plugin_extract_css, rspack_plugin_hmr, and the full workspace excluding binding crates per contribution guide) — all green.

Checklist

  • Tests updated (four new/regenerated test cases; existing hot-update snapshots regenerated and reviewed; one pre-existing test's assertion fixed).
  • Documentation updated (not required — no public API/config surface changed).

claude added 7 commits July 15, 2026 14:32
editing CSS never updated the page when CssExtractRspackPlugin used a
hashed filename ([contenthash]). The runtime's `miniCssF` function is
baked into the runtime chunk as a string literal and is never
re-evaluated by HMR, so it kept returning the build-1 filename forever.

Extend the hot-update manifest with a `css` map (chunk id -> freshly
emitted CSS filename) computed from the chunk's own emitted files,
independent of the (unreliable) `miniCssF` runtime function. Thread it
through a new `HMR_CSS_FILENAMES` runtime global (__webpack_require__.hmrCF)
that the extract-css HMR handler now prefers over `miniCssF`.

Locating the currently installed <link> tag also needed a fix: once the
href changes, exact href matching can no longer find it. Tag
runtime-created links with the chunk id (data-webpack-chunk) and match
on that first; fall back to the frozen miniCssF href only for the very
first update, since that is the only case where the tag was written
directly into the HTML by HtmlRspackPlugin and never tagged.

Add a regression e2e test with a hashed extract-css filename that
asserts the computed style actually updates across two edits in a row,
and that exactly one stylesheet link remains.
Keeps the hot-update manifest shape unchanged for builds that don't
touch CSS, instead of always emitting an empty css object.
Regenerated via rstest -u; the only change is the new css chunk-id ->
filename map added to the hot-update manifest.
…matching, and cross-app identity

Three follow-up fixes from review:

- Lazy chunks loaded after an HMR update still requested the stale filename: the
  loading, prefetch, and preload paths only ever called the (frozen) `miniCssF`,
  never the retained fresh mapping. They now prefer HMR_MINI_CSS_FILENAMES too,
  which is exactly what web-infra-dev#6869 is about (lazy components).
- The hot-update manifest picked "the first chunk file ending in .css", which can
  select the wrong asset when a chunk carries both native and extracted CSS, or
  another plugin emits its own .css file into the same chunk. Match by asset
  ownership (ManifestAssetType::Custom("extract-css"), same tag rspack_plugin_sri
  already relies on) instead. Renamed the manifest field from the generic "css" to
  "miniCss" (and HMR_CSS_FILENAMES to HMR_MINI_CSS_FILENAMES) to keep this
  extract-css-specific channel distinct from any native-CSS equivalent.
- The stylesheet identity attribute was scoped only to the chunk id, which two
  independently compiled apps sharing the same chunk id (e.g. both "main") could
  collide on. Namespaced it with output.uniqueName, consistent with the identity
  scheme chunk/module script loading already uses (load_script.rs).

Adds an e2e case that edits a lazy chunk's CSS before its first import().
Regenerated via rstest -u. Extract-css cases now show "miniCss" instead of
"css"; native-CSS cases (type: 'css') correctly lost the field entirely,
since it's tagged by extract-css asset ownership now instead of a ".css"
filename-suffix heuristic that happened to also match native CSS assets.
CssLoadingRuntimeModule::runtime_requirements() only forwarded the
create-link template's weak requirements, not the basic template's own
(which now includes the HMR_MINI_CSS_FILENAMES read added for lazy-chunk
support). Under experiments.runtimeMode: "rspack", a weak read compiles to
a bare lexical identifier rather than a safe property access, so loading
an async extracted-CSS chunk with no HMR runtime module present threw a
ReferenceError instead of falling back to miniCssF. Covered by a new
non-HMR runtimeMode: "rspack" config test.

Also fixes a pre-existing false positive this surfaced: css-extract/with-
contenthash's "no full hash runtime" check used a plain substring match
that incidentally matched the new hmrMCF property name (any property
starting with "h" would trip it). Tightened it to a word-boundary regex.
…/CSS drop

currentUpdateMiniCssFilenames only ever accumulated. Because css_loading
consults it with priority over miniCssF when first loading a chunk, a lingering
entry becomes a guaranteed 404 rather than mere staleness in two cases:

- a chunk rebuilt without an extract-css asset (its CSS import was removed):
  no new miniCss entry is written but the old one persists. Such a chunk is
  always present in the manifest's `c` list yet absent from `miniCss` (the
  manifest reports miniCss iff the current chunk carries an extract-css asset),
  so delete any retained entry for `c` chunks not in `miniCss`.
- a chunk in the manifest's `r` (removed) list: delete its retained entry too.

A later first-time import() of such a chunk now resolves miniCssF (or nothing)
instead of preferring a href the dev server no longer holds.

Also document why the lazy-chunk e2e test waits on HMR apply completion (the
repo's own hot client message) before triggering the first import().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEYqP3tNtaUW3RarNrSwLw
@chenjiahan
chenjiahan requested a review from stormslowly July 17, 2026 02:14
@codspeed-hq

codspeed-hq Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 43 untouched benchmarks
⏩ 47 skipped benchmarks1


Comparing gultyayev:claude/css-hmr-contenthash-stale-mxgwm8 (dadeede) with main (58d8d6b)

Open in CodSpeed

Footnotes

  1. 47 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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.

2 participants