From 252cb41fc4be553fa135e4b1979f3f8c5dcd9289 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 14:32:15 +0000 Subject: [PATCH 1/8] fix(css-extract): fix stale CSS HMR when extracted filename is hashed 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 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. --- crates/rspack_core/src/runtime_globals.rs | 8 +++++ .../src/runtime/css_loading.ejs | 8 ++++- .../src/runtime/css_loading_create_link.ejs | 1 + .../src/runtime/css_loading_with_hmr.ejs | 20 +++++++++-- crates/rspack_plugin_hmr/src/lib.rs | 19 +++++++++++ .../src/runtime/hot_module_replacement.ejs | 10 ++++++ .../cases/css/contenthash-hmr/index.test.ts | 30 ++++++++++++++++ .../css/contenthash-hmr/rspack.config.js | 34 +++++++++++++++++++ .../cases/css/contenthash-hmr/src/index.css | 3 ++ .../cases/css/contenthash-hmr/src/index.html | 7 ++++ .../cases/css/contenthash-hmr/src/index.js | 3 ++ 11 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/cases/css/contenthash-hmr/index.test.ts create mode 100644 tests/e2e/cases/css/contenthash-hmr/rspack.config.js create mode 100644 tests/e2e/cases/css/contenthash-hmr/src/index.css create mode 100644 tests/e2e/cases/css/contenthash-hmr/src/index.html create mode 100644 tests/e2e/cases/css/contenthash-hmr/src/index.js diff --git a/crates/rspack_core/src/runtime_globals.rs b/crates/rspack_core/src/runtime_globals.rs index fc6c29aeb31d..6ea539d86c4d 100644 --- a/crates/rspack_core/src/runtime_globals.rs +++ b/crates/rspack_core/src/runtime_globals.rs @@ -291,6 +291,13 @@ define_runtime_globals! { const HAS_FETCH_PRIORITY; + /** + * object mapping updated chunk ids to the CSS filenames emitted for them by the + * in-flight hot update, so CSS HMR handlers can resolve the fresh stylesheet URL + * instead of relying on the (possibly stale) chunk filename runtime function + */ + const HMR_CSS_FILENAMES; + // amd module support const AMD_DEFINE; const AMD_OPTIONS; @@ -449,6 +456,7 @@ pub fn runtime_globals_property_name(runtime_globals: &RuntimeGlobals) -> Option RuntimeGlobals::CSS_STYLE_SHEET => "css", RuntimeGlobals::ASYNC_STARTUP => "asyncStartup", RuntimeGlobals::HAS_FETCH_PRIORITY => "has fetch priority", + RuntimeGlobals::HMR_CSS_FILENAMES => "hmrCF", RuntimeGlobals::RSC_MANIFEST => "rscM", RuntimeGlobals::TO_BINARY => "tb", diff --git a/crates/rspack_plugin_extract_css/src/runtime/css_loading.ejs b/crates/rspack_plugin_extract_css/src/runtime/css_loading.ejs index f605c32816cf..ece7599951f0 100644 --- a/crates/rspack_plugin_extract_css/src/runtime/css_loading.ejs +++ b/crates/rspack_plugin_extract_css/src/runtime/css_loading.ejs @@ -23,10 +23,16 @@ var extractCssCreateStylesheet = function ( <%- _insert %> return linkTag; } -var extractCssFindStylesheet = function (href, fullhref) { +var extractCssFindStylesheet = function (href, fullhref, chunkId) { var existingLinkTags = document.getElementsByTagName("link"); for (var i = 0; i < existingLinkTags.length; i++) { var tag = existingLinkTags[i]; + // When a chunkId is given (HMR replacement lookup), prefer matching the tag that + // was created for that chunk: the href it was created with may since have changed + // (e.g. a hashed filename), so it can no longer be found by comparing hrefs. + if (chunkId !== undefined && tag.getAttribute("data-webpack-chunk") === String(chunkId)) { + return tag; + } var dataHref = tag.getAttribute("data-href") || tag.getAttribute("href"); if (dataHref) { dataHref = dataHref.split('?')[0] diff --git a/crates/rspack_plugin_extract_css/src/runtime/css_loading_create_link.ejs b/crates/rspack_plugin_extract_css/src/runtime/css_loading_create_link.ejs index 7cd766fab47b..bcabf6b4290c 100644 --- a/crates/rspack_plugin_extract_css/src/runtime/css_loading_create_link.ejs +++ b/crates/rspack_plugin_extract_css/src/runtime/css_loading_create_link.ejs @@ -1,5 +1,6 @@ var linkTag = document.createElement("link"); <%- _set_attributes %> +linkTag.setAttribute("data-webpack-chunk", chunkId); linkTag.rel = "stylesheet"; <% if (_set_linktype != "") { %> linkTag.type = <%- _set_linktype %>; diff --git a/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_hmr.ejs b/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_hmr.ejs index a3a56562a918..24172eff102f 100644 --- a/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_hmr.ejs +++ b/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_hmr.ejs @@ -21,12 +21,28 @@ var extractCssApplyHandler = function (options) { applyHandlers.push(extractCssApplyHandler); var seen = {}; chunkIds.forEach(function (chunkId) { - var href = <%- REQUIRE_SCOPE %>.miniCssF(chunkId); + /** + The runtime chunk's own `miniCssF` literal is baked in at build time and never + re-fetched by HMR, so it goes stale whenever the CSS filename is hashed + ([contenthash]). `HMR_CSS_FILENAMES` is populated from the hot-update manifest + with the freshly emitted filename for this update and takes priority; `miniCssF` + remains the fallback for chunks the manifest didn't report (e.g. unhashed names). + */ + var staleHref = <%- REQUIRE_SCOPE %>.miniCssF(chunkId); + var href = <%- HMR_CSS_FILENAMES %>[chunkId] || staleHref; if (!href) return; var fullhref = <%- PUBLIC_PATH %> + href; if (seen[fullhref]) return; seen[fullhref] = true; - var oldTag = extractCssFindStylesheet(href, fullhref); + /** + Find the currently installed stylesheet primarily by chunk identity (set on tags + this handler previously created), so repeated updates keep tracking the right tag + even as the href keeps changing. The very first update after a page load has + nothing to match by chunk identity yet, since that tag was written directly into + the HTML by HtmlRspackPlugin - fall back to the stale (frozen at initial-load) + `miniCssF` href, which still matches that tag's real, unmodified href. + */ + var oldTag = extractCssFindStylesheet(staleHref, <%- PUBLIC_PATH %> + staleHref, chunkId); if (!oldTag) return; promises.push(new Promise(function (resolve, reject) { var tag = extractCssCreateStylesheet( diff --git a/crates/rspack_plugin_hmr/src/lib.rs b/crates/rspack_plugin_hmr/src/lib.rs index 01800f81a42a..0c39a9a4fde1 100644 --- a/crates/rspack_plugin_hmr/src/lib.rs +++ b/crates/rspack_plugin_hmr/src/lib.rs @@ -130,6 +130,13 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { .find(|(_, chunk)| chunk.expect_id().eq(&chunk_id)) .map(|(_, chunk)| chunk); let current_chunk_ukey = current_chunk.map(|c| c.ukey()); + let updated_css_filename = current_chunk.and_then(|chunk| { + chunk + .files() + .iter() + .find(|filename| filename.ends_with(".css")) + .cloned() + }); if let Some(current_chunk) = current_chunk { new_runtime = current_chunk @@ -338,6 +345,11 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { new_runtime.iter().for_each(|runtime| { if let Some(info) = hot_update_main_content_by_runtime.get_mut(runtime) { info.updated_chunk_ids.insert(chunk_id.clone()); + if let Some(css_filename) = &updated_css_filename { + info + .css_filenames + .insert(chunk_id.clone(), css_filename.clone()); + } } }); } @@ -376,6 +388,7 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { .removed_chunk_ids .extend(content.removed_chunk_ids); old_content.removed_modules.extend(content.removed_modules); + old_content.css_filenames.extend(content.css_filenames); compilation.push_diagnostic(Diagnostic::warn( "HotModuleReplacementPlugin".to_string(), r#"The configured output.hotUpdateMainFilename doesn't lead to unique filenames per runtime and HMR update differs between runtimes. @@ -401,6 +414,7 @@ To fix this, make sure to include [runtime] in the output.hotUpdateMainFilename "c": c, "r": r, "m": m, + "css": content.css_filenames, }) .to_string(); @@ -502,4 +516,9 @@ struct HotUpdateContent { updated_chunk_ids: ChunkIdSet, removed_chunk_ids: ChunkIdSet, removed_modules: HashSet, + // CSS filename freshly emitted for an updated chunk, keyed by chunk id. Lets CSS HMR + // handlers resolve the current stylesheet URL directly instead of relying on the + // chunk filename runtime function, which is never re-evaluated by HMR and so cannot + // reflect a filename that includes a content hash. + css_filenames: HashMap, } diff --git a/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs b/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs index a59d89090eb6..e1370d07591a 100644 --- a/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs +++ b/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs @@ -1,4 +1,5 @@ var currentModuleData = {}; +var currentUpdateCssFilenames = {}; var hmrInstalledModules = <%- MODULE_CACHE %>; // module and require creation @@ -18,6 +19,7 @@ var currentUpdateApplyHandlers; var queuedInvalidatedModules; <%- define(HMR_MODULE_DATA) %> = currentModuleData; +<%- define(HMR_CSS_FILENAMES) %> = currentUpdateCssFilenames; <% if (_is_rspack_runtime_mode) { %><%- define(INTERCEPT_MODULE_EXECUTION) %> = []; <% } %> <%- INTERCEPT_MODULE_EXECUTION %>.push(function (options) { @@ -262,6 +264,14 @@ function hotCheck(applyOnUpdate) { return setStatus("prepare").then(function () { var updatedModules = []; currentUpdateApplyHandlers = []; + // Mutate in place: HMR_CSS_FILENAMES was handed out as a reference to this + // same object, so reassigning the variable here would leave that reference + // pointing at the old (stale) object instead of this update's data. + if (update.css) { + for (var cssChunkId in update.css) { + currentUpdateCssFilenames[cssChunkId] = update.css[cssChunkId]; + } + } return Promise.all( Object.keys(<%- HMR_DOWNLOAD_UPDATE_HANDLERS %>).reduce(function ( diff --git a/tests/e2e/cases/css/contenthash-hmr/index.test.ts b/tests/e2e/cases/css/contenthash-hmr/index.test.ts new file mode 100644 index 000000000000..c4b953bf8aab --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr/index.test.ts @@ -0,0 +1,30 @@ +import { test, expect } from '@/fixtures'; + +const COLOR_BLUE = 'rgb(10, 20, 30)'; +const COLOR_RED = 'rgb(120, 0, 0)'; +const COLOR_GREEN = 'rgb(0, 90, 0)'; + +// With a hashed extract-css filename, the runtime's `miniCssF` literal is baked in at +// build time and never re-fetched by HMR, so every edit resolved to the same (stale) +// href and got silently discarded. See rspack#6869. +test('should update the page and keep a single stylesheet link when the extracted CSS filename is hashed', async ({ + page, + fileAction, +}) => { + const links = page.locator('link[rel="stylesheet"]'); + await expect(page.locator('body')).toHaveCSS('background-color', COLOR_BLUE); + await expect(links).toHaveCount(1); + + fileAction.updateFile('src/index.css', (content) => + content.replace(COLOR_BLUE, COLOR_RED), + ); + await expect(page.locator('body')).toHaveCSS('background-color', COLOR_RED); + await expect(links).toHaveCount(1); + + // Not just an off-by-one: the second edit in a row must also apply. + fileAction.updateFile('src/index.css', (content) => + content.replace(COLOR_RED, COLOR_GREEN), + ); + await expect(page.locator('body')).toHaveCSS('background-color', COLOR_GREEN); + await expect(links).toHaveCount(1); +}); diff --git a/tests/e2e/cases/css/contenthash-hmr/rspack.config.js b/tests/e2e/cases/css/contenthash-hmr/rspack.config.js new file mode 100644 index 000000000000..b4fd5c3ed7da --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr/rspack.config.js @@ -0,0 +1,34 @@ +const { rspack } = require('@rspack/core'); + +/** @type { import('@rspack/core').RspackOptions } */ +module.exports = { + context: __dirname, + mode: 'development', + entry: { + main: ['./src/index.css', './src/index.js'], + }, + devServer: { + hot: true, + }, + plugins: [ + new rspack.HtmlRspackPlugin({ + template: './src/index.html', + inject: 'body', + }), + // A hashed filename is the whole point of this case: the runtime's cached + // `miniCssF` literal goes stale on every edit unless the fresh href is + // threaded through the hot update instead. + new rspack.CssExtractRspackPlugin({ + filename: '[name].[contenthash:8].css', + }), + ], + module: { + rules: [ + { + test: /\.css$/, + type: 'javascript/auto', + use: [rspack.CssExtractRspackPlugin.loader, 'css-loader'], + }, + ], + }, +}; diff --git a/tests/e2e/cases/css/contenthash-hmr/src/index.css b/tests/e2e/cases/css/contenthash-hmr/src/index.css new file mode 100644 index 000000000000..6542fae55119 --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr/src/index.css @@ -0,0 +1,3 @@ +body { + background-color: rgb(10, 20, 30); +} diff --git a/tests/e2e/cases/css/contenthash-hmr/src/index.html b/tests/e2e/cases/css/contenthash-hmr/src/index.html new file mode 100644 index 000000000000..7c26f485c41b --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr/src/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/e2e/cases/css/contenthash-hmr/src/index.js b/tests/e2e/cases/css/contenthash-hmr/src/index.js new file mode 100644 index 000000000000..b831a14b164b --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr/src/index.js @@ -0,0 +1,3 @@ +import './index.css'; + +module.hot.accept(); From 7a8e39f26dae6e3f4cb84bd0ec0add3eb8ab9cc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 14:32:18 +0000 Subject: [PATCH 2/8] fix(hmr): only emit css manifest field when a chunk has a CSS update Keeps the hot-update manifest shape unchanged for builds that don't touch CSS, instead of always emitting an empty css object. --- crates/rspack_plugin_hmr/src/lib.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/rspack_plugin_hmr/src/lib.rs b/crates/rspack_plugin_hmr/src/lib.rs index 0c39a9a4fde1..e2a9c5f7e9da 100644 --- a/crates/rspack_plugin_hmr/src/lib.rs +++ b/crates/rspack_plugin_hmr/src/lib.rs @@ -410,13 +410,15 @@ To fix this, make sure to include [runtime] in the output.hotUpdateMainFilename m.into_iter().collect() }; - let manifest_content = serde_json::json!({ + let mut manifest = serde_json::json!({ "c": c, "r": r, "m": m, - "css": content.css_filenames, - }) - .to_string(); + }); + if !content.css_filenames.is_empty() { + manifest["css"] = serde_json::json!(content.css_filenames); + } + let manifest_content = manifest.to_string(); compilation.emit_asset( filename, From 4e813e07a7c6d1e5f2f25e78fdc1b963d809b3d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 14:32:21 +0000 Subject: [PATCH 3/8] test: update hot-update manifest snapshots for new css field Regenerated via rstest -u; the only change is the new css chunk-id -> filename map added to the hot-update manifest. --- .../css/css-loading-unique-name/__snapshots__/web/1.snap.txt | 4 ++-- .../hotCases/css/css-modules/__snapshots__/web/1.snap.txt | 4 ++-- .../hotCases/css/fetch-priority/__snapshots__/web/1.snap.txt | 4 ++-- .../css/recovery-cacheable/__snapshots__/web/2.snap.txt | 4 ++-- .../hotCases/css/recovery/__snapshots__/web/2.snap.txt | 4 ++-- .../hotCases/css/vanilla/__snapshots__/web/1.snap.txt | 4 ++-- .../import-order-change/__snapshots__/web/1.snap.txt | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/rspack-test/hotCases/css/css-loading-unique-name/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/css/css-loading-unique-name/__snapshots__/web/1.snap.txt index 18739651fdee..2d3f7f9940f7 100644 --- a/tests/rspack-test/hotCases/css/css-loading-unique-name/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/css/css-loading-unique-name/__snapshots__/web/1.snap.txt @@ -5,7 +5,7 @@ ## Asset Files - Bundle: bundle.js -- Manifest: main.LAST_HASH.hot-update.json, size: 28 +- Manifest: main.LAST_HASH.hot-update.json, size: 56 - Update: main.LAST_HASH.hot-update.js, size: 331 ## Manifest @@ -13,7 +13,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main"],"r":[],"m":[]} +{"c":["main"],"r":[],"m":[],"css":{"main":"bundle.css"}} ``` diff --git a/tests/rspack-test/hotCases/css/css-modules/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/css/css-modules/__snapshots__/web/1.snap.txt index b75e17851d74..ac90bfbb3fdb 100644 --- a/tests/rspack-test/hotCases/css/css-modules/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/css/css-modules/__snapshots__/web/1.snap.txt @@ -7,7 +7,7 @@ ## Asset Files - Bundle: bundle.js - Bundle: style2_module_css.chunk.CURRENT_HASH.js -- Manifest: main.LAST_HASH.hot-update.json, size: 48 +- Manifest: main.LAST_HASH.hot-update.json, size: 143 - Update: main.LAST_HASH.hot-update.js, size: 708 - Update: style2_module_css.LAST_HASH.hot-update.js, size: 576 @@ -16,7 +16,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main","style2_module_css"],"r":[],"m":[]} +{"c":["main","style2_module_css"],"r":[],"m":[],"css":{"style2_module_css":"style2_module_css.chunk.CURRENT_HASH.css","main":"bundle.css"}} ``` diff --git a/tests/rspack-test/hotCases/css/fetch-priority/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/css/fetch-priority/__snapshots__/web/1.snap.txt index f90e067d1571..8bdb722df0cb 100644 --- a/tests/rspack-test/hotCases/css/fetch-priority/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/css/fetch-priority/__snapshots__/web/1.snap.txt @@ -6,7 +6,7 @@ ## Asset Files - Bundle: bundle.js - Bundle: style_module_css.chunk.CURRENT_HASH.js -- Manifest: main.LAST_HASH.hot-update.json, size: 47 +- Manifest: main.LAST_HASH.hot-update.json, size: 120 - Update: main.LAST_HASH.hot-update.js, size: 181 - Update: style_module_css.LAST_HASH.hot-update.js, size: 589 @@ -15,7 +15,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main","style_module_css"],"r":[],"m":[]} +{"c":["main","style_module_css"],"r":[],"m":[],"css":{"style_module_css":"style_module_css.chunk.CURRENT_HASH.css"}} ``` diff --git a/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt b/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt index 56801c282e5e..14923d5d0e02 100644 --- a/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt +++ b/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt @@ -5,7 +5,7 @@ ## Asset Files - Bundle: bundle.js -- Manifest: main.LAST_HASH.hot-update.json, size: 28 +- Manifest: main.LAST_HASH.hot-update.json, size: 54 - Update: main.hot-update.js, size: 993 ## Manifest @@ -13,7 +13,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main"],"r":[],"m":[]} +{"c":["main"],"r":[],"m":[],"css":{"main":"main.css"}} ``` diff --git a/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt b/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt index c95656f6c6eb..7b37d8346324 100644 --- a/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt +++ b/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt @@ -5,7 +5,7 @@ ## Asset Files - Bundle: bundle.js -- Manifest: main.LAST_HASH.hot-update.json, size: 28 +- Manifest: main.LAST_HASH.hot-update.json, size: 54 - Update: main.hot-update.js, size: 6038 ## Manifest @@ -13,7 +13,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main"],"r":[],"m":[]} +{"c":["main"],"r":[],"m":[],"css":{"main":"main.css"}} ``` diff --git a/tests/rspack-test/hotCases/css/vanilla/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/css/vanilla/__snapshots__/web/1.snap.txt index 8ee7497fb818..c4fc2958d247 100644 --- a/tests/rspack-test/hotCases/css/vanilla/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/css/vanilla/__snapshots__/web/1.snap.txt @@ -7,7 +7,7 @@ ## Asset Files - Bundle: bundle.js - Bundle: style2_css.chunk.CURRENT_HASH.js -- Manifest: main.LAST_HASH.hot-update.json, size: 41 +- Manifest: main.LAST_HASH.hot-update.json, size: 99 - Update: main.LAST_HASH.hot-update.js, size: 181 - Update: style2_css.LAST_HASH.hot-update.js, size: 199 @@ -16,7 +16,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main","style2_css"],"r":[],"m":[]} +{"c":["main","style2_css"],"r":[],"m":[],"css":{"main":"bundle.css","style2_css":"style2_css.css"}} ``` diff --git a/tests/rspack-test/hotCases/module-graph/import-order-change/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/module-graph/import-order-change/__snapshots__/web/1.snap.txt index 98d68fee5d90..2013f1e14411 100644 --- a/tests/rspack-test/hotCases/module-graph/import-order-change/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/module-graph/import-order-change/__snapshots__/web/1.snap.txt @@ -5,7 +5,7 @@ ## Asset Files - Bundle: bundle.js -- Manifest: main.LAST_HASH.hot-update.json, size: 28 +- Manifest: main.LAST_HASH.hot-update.json, size: 56 - Update: main.LAST_HASH.hot-update.js, size: 466 ## Manifest @@ -13,7 +13,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main"],"r":[],"m":[]} +{"c":["main"],"r":[],"m":[],"css":{"main":"bundle.css"}} ``` From 7d7e89a5cab6f6f1ce03d1a4acc06b84c020a442 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:25:15 +0000 Subject: [PATCH 4/8] fix(css-extract): fix HMR gaps in lazy chunk loading, manifest asset 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 #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(). --- crates/rspack_core/src/runtime_globals.rs | 13 +++-- .../rspack_plugin_extract_css/src/runtime.rs | 12 +++++ .../src/runtime/css_loading.ejs | 18 ++++++- .../src/runtime/css_loading_create_link.ejs | 2 +- .../src/runtime/css_loading_with_hmr.ejs | 4 +- .../css_loading_with_prefetch_link.ejs | 3 +- .../runtime/css_loading_with_preload_link.ejs | 3 +- crates/rspack_plugin_hmr/src/lib.rs | 49 +++++++++++++------ .../src/runtime/hot_module_replacement.ejs | 16 +++--- .../contenthash-hmr-lazy-chunk/index.test.ts | 29 +++++++++++ .../rspack.config.js | 34 +++++++++++++ .../contenthash-hmr-lazy-chunk/src/index.html | 9 ++++ .../contenthash-hmr-lazy-chunk/src/index.js | 5 ++ .../contenthash-hmr-lazy-chunk/src/lazy.css | 3 ++ .../contenthash-hmr-lazy-chunk/src/lazy.js | 1 + 15 files changed, 166 insertions(+), 35 deletions(-) create mode 100644 tests/e2e/cases/css/contenthash-hmr-lazy-chunk/index.test.ts create mode 100644 tests/e2e/cases/css/contenthash-hmr-lazy-chunk/rspack.config.js create mode 100644 tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/index.html create mode 100644 tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/index.js create mode 100644 tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/lazy.css create mode 100644 tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/lazy.js diff --git a/crates/rspack_core/src/runtime_globals.rs b/crates/rspack_core/src/runtime_globals.rs index 6ea539d86c4d..b08dd3bd7e55 100644 --- a/crates/rspack_core/src/runtime_globals.rs +++ b/crates/rspack_core/src/runtime_globals.rs @@ -292,11 +292,14 @@ define_runtime_globals! { const HAS_FETCH_PRIORITY; /** - * object mapping updated chunk ids to the CSS filenames emitted for them by the - * in-flight hot update, so CSS HMR handlers can resolve the fresh stylesheet URL - * instead of relying on the (possibly stale) chunk filename runtime function + * object mapping chunk ids to the extract-css filenames emitted for them by hot + * updates seen so far, so the extract-css HMR handler and its chunk loading can + * resolve the fresh stylesheet URL instead of relying on the (possibly stale) + * chunk filename runtime function. Namespaced as "mini css" (matching the + * `miniCssF` chunk filename function) to stay distinct from any equivalent + * mechanism for the native (non-extracted) CSS runtime. */ - const HMR_CSS_FILENAMES; + const HMR_MINI_CSS_FILENAMES; // amd module support const AMD_DEFINE; @@ -456,7 +459,7 @@ pub fn runtime_globals_property_name(runtime_globals: &RuntimeGlobals) -> Option RuntimeGlobals::CSS_STYLE_SHEET => "css", RuntimeGlobals::ASYNC_STARTUP => "asyncStartup", RuntimeGlobals::HAS_FETCH_PRIORITY => "has fetch priority", - RuntimeGlobals::HMR_CSS_FILENAMES => "hmrCF", + RuntimeGlobals::HMR_MINI_CSS_FILENAMES => "hmrMCF", RuntimeGlobals::RSC_MANIFEST => "rscM", RuntimeGlobals::TO_BINARY => "tb", diff --git a/crates/rspack_plugin_extract_css/src/runtime.rs b/crates/rspack_plugin_extract_css/src/runtime.rs index 482abcaff48f..c06b0ae1553d 100644 --- a/crates/rspack_plugin_extract_css/src/runtime.rs +++ b/crates/rspack_plugin_extract_css/src/runtime.rs @@ -272,6 +272,17 @@ impl RuntimeModule for CssLoadingRuntimeModule { } let mut res = vec![]; + // Namespaces the chunk-identity attribute used to find a currently installed + // stylesheet by output.uniqueName, same as script loading's own chunk/module + // identity scheme (see GetChunkFilenameRuntimeModule / load_script.rs). Left + // unprefixed when unset, matching that scheme's own behavior. + let unique_name = &compilation.options.output.unique_name; + let unique_name_prefix = if unique_name.is_empty() { + "\"\"".to_string() + } else { + rspack_util::json_stringify_str(&format!("{unique_name}:")) + }; + let create_link_raw = runtime_template.render( &self.template_id(TemplateId::CreateLink), Some(serde_json::json!({ @@ -298,6 +309,7 @@ impl RuntimeModule for CssLoadingRuntimeModule { &self.template_id(TemplateId::Raw), Some(serde_json::json!({ "_create_link": &create_link.code, + "_unique_name_prefix": &unique_name_prefix, "_insert": match &self.insert { InsertType::Fn(f) => format!("({f})(linkTag);"), InsertType::Selector(sel) => format!("var target = document.querySelector({sel});\ntarget.parentNode.insertBefore(linkTag, target.nextSibling);"), diff --git a/crates/rspack_plugin_extract_css/src/runtime/css_loading.ejs b/crates/rspack_plugin_extract_css/src/runtime/css_loading.ejs index ece7599951f0..66655925a703 100644 --- a/crates/rspack_plugin_extract_css/src/runtime/css_loading.ejs +++ b/crates/rspack_plugin_extract_css/src/runtime/css_loading.ejs @@ -1,4 +1,10 @@ if (typeof document === "undefined") return; +// Scopes the chunk-identity attribute used to find a currently installed stylesheet +// (see extractCssFindStylesheet) to this compilation, so two independently compiled +// apps sharing a chunk id on the same page can't steal or remove each other's +// stylesheet. Matches the same output.uniqueName-based identity scheme chunk/module +// script loading already uses (see load_script.ejs). +var extractCssUniquePrefix = <%- _unique_name_prefix %>; var extractCssCreateStylesheet = function ( chunkId, fullhref, oldTag, resolve, reject, fetchPriority ) { @@ -30,7 +36,7 @@ var extractCssFindStylesheet = function (href, fullhref, chunkId) { // When a chunkId is given (HMR replacement lookup), prefer matching the tag that // was created for that chunk: the href it was created with may since have changed // (e.g. a hashed filename), so it can no longer be found by comparing hrefs. - if (chunkId !== undefined && tag.getAttribute("data-webpack-chunk") === String(chunkId)) { + if (chunkId !== undefined && tag.getAttribute("data-webpack-chunk") === extractCssUniquePrefix + String(chunkId)) { return tag; } var dataHref = tag.getAttribute("data-href") || tag.getAttribute("href"); @@ -50,7 +56,15 @@ var extractCssFindStylesheet = function (href, fullhref, chunkId) { var extractCssLoadStylesheet = function (chunkId, fetchPriority) { return new Promise(function (resolve, reject) { - var href = <%- REQUIRE_SCOPE %>.miniCssF(chunkId); + /** + A chunk that hasn't been loaded yet (e.g. a lazy route not imported so far) has + no tag for the HMR handler to update, so it instead just retains the fresh + filename it learned about (see HMR_MINI_CSS_FILENAMES in the with-hmr runtime). + Consult that retained mapping here too, or loading the chunk for the first time + after its CSS changed would still request the stale, pre-edit filename. + */ + var miniCssFilenames = <%- weak(HMR_MINI_CSS_FILENAMES) %>; + var href = (miniCssFilenames && miniCssFilenames[chunkId]) || <%- REQUIRE_SCOPE %>.miniCssF(chunkId); var fullhref = <%- PUBLIC_PATH %> + href; if (extractCssFindStylesheet(href, fullhref)) return resolve(); extractCssCreateStylesheet(chunkId, fullhref, null, resolve, reject, fetchPriority); diff --git a/crates/rspack_plugin_extract_css/src/runtime/css_loading_create_link.ejs b/crates/rspack_plugin_extract_css/src/runtime/css_loading_create_link.ejs index bcabf6b4290c..45fe2faea3d0 100644 --- a/crates/rspack_plugin_extract_css/src/runtime/css_loading_create_link.ejs +++ b/crates/rspack_plugin_extract_css/src/runtime/css_loading_create_link.ejs @@ -1,6 +1,6 @@ var linkTag = document.createElement("link"); <%- _set_attributes %> -linkTag.setAttribute("data-webpack-chunk", chunkId); +linkTag.setAttribute("data-webpack-chunk", extractCssUniquePrefix + chunkId); linkTag.rel = "stylesheet"; <% if (_set_linktype != "") { %> linkTag.type = <%- _set_linktype %>; diff --git a/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_hmr.ejs b/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_hmr.ejs index 24172eff102f..87c71d554123 100644 --- a/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_hmr.ejs +++ b/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_hmr.ejs @@ -24,12 +24,12 @@ var extractCssApplyHandler = function (options) { /** The runtime chunk's own `miniCssF` literal is baked in at build time and never re-fetched by HMR, so it goes stale whenever the CSS filename is hashed - ([contenthash]). `HMR_CSS_FILENAMES` is populated from the hot-update manifest + ([contenthash]). `HMR_MINI_CSS_FILENAMES` is populated from the hot-update manifest with the freshly emitted filename for this update and takes priority; `miniCssF` remains the fallback for chunks the manifest didn't report (e.g. unhashed names). */ var staleHref = <%- REQUIRE_SCOPE %>.miniCssF(chunkId); - var href = <%- HMR_CSS_FILENAMES %>[chunkId] || staleHref; + var href = <%- HMR_MINI_CSS_FILENAMES %>[chunkId] || staleHref; if (!href) return; var fullhref = <%- PUBLIC_PATH %> + href; if (seen[fullhref]) return; diff --git a/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_prefetch_link.ejs b/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_prefetch_link.ejs index 410e4191e61c..bf8b8ffb30fc 100644 --- a/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_prefetch_link.ejs +++ b/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_prefetch_link.ejs @@ -7,4 +7,5 @@ if (<%- weak(SCRIPT_NONCE) %>) { } link.rel = "prefetch"; link.as = "style"; -link.href = <%- PUBLIC_PATH %> + <%- REQUIRE_SCOPE %>.miniCssF(chunkId); +var extractCssPrefetchMiniCssFilenames = <%- weak(HMR_MINI_CSS_FILENAMES) %>; +link.href = <%- PUBLIC_PATH %> + ((extractCssPrefetchMiniCssFilenames && extractCssPrefetchMiniCssFilenames[chunkId]) || <%- REQUIRE_SCOPE %>.miniCssF(chunkId)); diff --git a/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_preload_link.ejs b/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_preload_link.ejs index b13540aaa188..29eaaf8a1ed4 100644 --- a/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_preload_link.ejs +++ b/crates/rspack_plugin_extract_css/src/runtime/css_loading_with_preload_link.ejs @@ -4,7 +4,8 @@ if (<%- weak(SCRIPT_NONCE) %>) { } link.rel = "preload"; link.as = "style"; -link.href = <%- PUBLIC_PATH %> + <%- REQUIRE_SCOPE %>.miniCssF(chunkId); +var extractCssPreloadMiniCssFilenames = <%- weak(HMR_MINI_CSS_FILENAMES) %>; +link.href = <%- PUBLIC_PATH %> + ((extractCssPreloadMiniCssFilenames && extractCssPreloadMiniCssFilenames[chunkId]) || <%- REQUIRE_SCOPE %>.miniCssF(chunkId)); <% if (_cross_origin == "use-credentials") { %> link.crossOrigin = "use-credentials"; <% } else if (_cross_origin != "") { %> diff --git a/crates/rspack_plugin_hmr/src/lib.rs b/crates/rspack_plugin_hmr/src/lib.rs index e2a9c5f7e9da..6cade4560874 100644 --- a/crates/rspack_plugin_hmr/src/lib.rs +++ b/crates/rspack_plugin_hmr/src/lib.rs @@ -8,9 +8,9 @@ use rspack_core::{ AssetInfo, Chunk, ChunkGraph, ChunkKind, ChunkUkey, Compilation, CompilationAdditionalTreeRuntimeRequirements, CompilationAsset, CompilationParams, CompilationProcessAssets, CompilationRecords, CompilerCompilation, DependencyType, LoaderContext, - ModuleId, ModuleIdentifier, ModuleType, NormalModuleFactoryParser, NormalModuleLoader, - ParserAndGenerator, ParserOptions, PathData, Plugin, RunnerContext, RuntimeGlobals, - RuntimeModule, RuntimeModuleExt, RuntimeSpec, + ManifestAssetType, ModuleId, ModuleIdentifier, ModuleType, NormalModuleFactoryParser, + NormalModuleLoader, ParserAndGenerator, ParserOptions, PathData, Plugin, RunnerContext, + RuntimeGlobals, RuntimeModule, RuntimeModuleExt, RuntimeSpec, chunk_graph_chunk::{ChunkId, ChunkIdSet}, rspack_sources::{RawStringSource, SourceExt}, }; @@ -130,11 +130,23 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { .find(|(_, chunk)| chunk.expect_id().eq(&chunk_id)) .map(|(_, chunk)| chunk); let current_chunk_ukey = current_chunk.map(|c| c.ukey()); - let updated_css_filename = current_chunk.and_then(|chunk| { + // Matched by asset ownership (the extract-css plugin tags its own assets with this + // type, same convention rspack_plugin_sri already relies on), not by filename suffix: + // a chunk can carry both native CSS and extract-css output, or another plugin's own + // `.css`-suffixed asset, and picking "the first `.css` file" could silently grab the + // wrong one. + let updated_mini_css_filename = current_chunk.and_then(|chunk| { chunk .files() .iter() - .find(|filename| filename.ends_with(".css")) + .find(|filename| { + compilation.assets().get(*filename).is_some_and(|asset| { + matches!( + &asset.info.asset_type, + ManifestAssetType::Custom(name) if name.as_str() == EXTRACT_CSS_ASSET_TYPE_NAME + ) + }) + }) .cloned() }); @@ -345,9 +357,9 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { new_runtime.iter().for_each(|runtime| { if let Some(info) = hot_update_main_content_by_runtime.get_mut(runtime) { info.updated_chunk_ids.insert(chunk_id.clone()); - if let Some(css_filename) = &updated_css_filename { + if let Some(css_filename) = &updated_mini_css_filename { info - .css_filenames + .mini_css_filenames .insert(chunk_id.clone(), css_filename.clone()); } } @@ -388,7 +400,9 @@ async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { .removed_chunk_ids .extend(content.removed_chunk_ids); old_content.removed_modules.extend(content.removed_modules); - old_content.css_filenames.extend(content.css_filenames); + old_content + .mini_css_filenames + .extend(content.mini_css_filenames); compilation.push_diagnostic(Diagnostic::warn( "HotModuleReplacementPlugin".to_string(), r#"The configured output.hotUpdateMainFilename doesn't lead to unique filenames per runtime and HMR update differs between runtimes. @@ -415,8 +429,8 @@ To fix this, make sure to include [runtime] in the output.hotUpdateMainFilename "r": r, "m": m, }); - if !content.css_filenames.is_empty() { - manifest["css"] = serde_json::json!(content.css_filenames); + if !content.mini_css_filenames.is_empty() { + manifest["miniCss"] = serde_json::json!(content.mini_css_filenames); } let manifest_content = manifest.to_string(); @@ -513,14 +527,19 @@ impl Plugin for HotModuleReplacementPlugin { } } +// Matches the asset type extract-css tags its own emitted assets with +// (see crates/rspack_plugin_extract_css/src/plugin.rs render_manifest, and the +// equivalent lookup in rspack_plugin_sri/src/runtime.rs). +const EXTRACT_CSS_ASSET_TYPE_NAME: &str = "extract-css"; + #[derive(Default)] struct HotUpdateContent { updated_chunk_ids: ChunkIdSet, removed_chunk_ids: ChunkIdSet, removed_modules: HashSet, - // CSS filename freshly emitted for an updated chunk, keyed by chunk id. Lets CSS HMR - // handlers resolve the current stylesheet URL directly instead of relying on the - // chunk filename runtime function, which is never re-evaluated by HMR and so cannot - // reflect a filename that includes a content hash. - css_filenames: HashMap, + // extract-css filename freshly emitted for an updated chunk, keyed by chunk id. Lets + // the extract-css HMR handler resolve the current stylesheet URL directly instead of + // relying on the chunk filename runtime function, which is never re-evaluated by HMR + // and so cannot reflect a filename that includes a content hash. + mini_css_filenames: HashMap, } diff --git a/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs b/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs index e1370d07591a..e6173502f1b1 100644 --- a/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs +++ b/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs @@ -1,5 +1,5 @@ var currentModuleData = {}; -var currentUpdateCssFilenames = {}; +var currentUpdateMiniCssFilenames = {}; var hmrInstalledModules = <%- MODULE_CACHE %>; // module and require creation @@ -19,7 +19,7 @@ var currentUpdateApplyHandlers; var queuedInvalidatedModules; <%- define(HMR_MODULE_DATA) %> = currentModuleData; -<%- define(HMR_CSS_FILENAMES) %> = currentUpdateCssFilenames; +<%- define(HMR_MINI_CSS_FILENAMES) %> = currentUpdateMiniCssFilenames; <% if (_is_rspack_runtime_mode) { %><%- define(INTERCEPT_MODULE_EXECUTION) %> = []; <% } %> <%- INTERCEPT_MODULE_EXECUTION %>.push(function (options) { @@ -264,12 +264,12 @@ function hotCheck(applyOnUpdate) { return setStatus("prepare").then(function () { var updatedModules = []; currentUpdateApplyHandlers = []; - // Mutate in place: HMR_CSS_FILENAMES was handed out as a reference to this - // same object, so reassigning the variable here would leave that reference - // pointing at the old (stale) object instead of this update's data. - if (update.css) { - for (var cssChunkId in update.css) { - currentUpdateCssFilenames[cssChunkId] = update.css[cssChunkId]; + // Mutate in place: HMR_MINI_CSS_FILENAMES was handed out as a reference to + // this same object, so reassigning the variable here would leave that + // reference pointing at the old (stale) object instead of this update's data. + if (update.miniCss) { + for (var cssChunkId in update.miniCss) { + currentUpdateMiniCssFilenames[cssChunkId] = update.miniCss[cssChunkId]; } } diff --git a/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/index.test.ts b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/index.test.ts new file mode 100644 index 000000000000..5de6df44e3fa --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/index.test.ts @@ -0,0 +1,29 @@ +import { test, expect } from '@/fixtures'; + +const COLOR_BLUE = 'rgb(10, 20, 30)'; +const COLOR_RED = 'rgb(120, 0, 0)'; + +// A chunk that hasn't been imported yet has no for the HMR handler to swap, so +// it can only retain the fresh filename for later. Chunk loading (triggered here by the +// first dynamic import()) must consult that retained filename too, or it falls back to +// the stale, pre-edit one. This is the scenario rspack#6869 itself is about (lazy +// components), so it's worth its own case distinct from the always-imported one. +test('should apply a CSS edit made before the chunk owning it is ever imported', async ({ + page, + fileAction, +}) => { + const hmrSettled = page.waitForEvent('console', { + predicate: (msg) => msg.text().includes('up to date'), + }); + + fileAction.updateFile('src/lazy.css', (content) => + content.replace(COLOR_BLUE, COLOR_RED), + ); + + await hmrSettled; + + await page.click('#load'); + + await expect(page.locator('body')).toHaveCSS('background-color', COLOR_RED); + await expect(page.locator('link[rel="stylesheet"]')).toHaveCount(1); +}); diff --git a/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/rspack.config.js b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/rspack.config.js new file mode 100644 index 000000000000..cf8b24545a09 --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/rspack.config.js @@ -0,0 +1,34 @@ +const { rspack } = require('@rspack/core'); + +/** @type { import('@rspack/core').RspackOptions } */ +module.exports = { + context: __dirname, + mode: 'development', + entry: { + main: './src/index.js', + }, + devServer: { + hot: true, + }, + plugins: [ + new rspack.HtmlRspackPlugin({ + template: './src/index.html', + inject: 'body', + }), + // A hashed filename is the whole point of this case: without it, the stale + // `miniCssF` literal a lazy chunk falls back to happens to still be correct + // (it never changes), so it wouldn't exercise the bug. + new rspack.CssExtractRspackPlugin({ + filename: '[name].[contenthash:8].css', + }), + ], + module: { + rules: [ + { + test: /\.css$/, + type: 'javascript/auto', + use: [rspack.CssExtractRspackPlugin.loader, 'css-loader'], + }, + ], + }, +}; diff --git a/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/index.html b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/index.html new file mode 100644 index 000000000000..2afecc79fa3f --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/index.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/index.js b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/index.js new file mode 100644 index 000000000000..4b098f2fee65 --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/index.js @@ -0,0 +1,5 @@ +document.getElementById('load').addEventListener('click', () => { + import('./lazy.js'); +}); + +module.hot.accept(); diff --git a/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/lazy.css b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/lazy.css new file mode 100644 index 000000000000..6542fae55119 --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/lazy.css @@ -0,0 +1,3 @@ +body { + background-color: rgb(10, 20, 30); +} diff --git a/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/lazy.js b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/lazy.js new file mode 100644 index 000000000000..bba7d747dcc4 --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/src/lazy.js @@ -0,0 +1 @@ +import './lazy.css'; From f9cd122504a0c7658095342882373139367c5d02 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 17:15:29 +0000 Subject: [PATCH 5/8] test: update hot-update manifest snapshots for the miniCss field rename 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. --- .../css/css-loading-unique-name/__snapshots__/web/1.snap.txt | 4 ++-- .../hotCases/css/css-modules/__snapshots__/web/1.snap.txt | 4 ++-- .../hotCases/css/fetch-priority/__snapshots__/web/1.snap.txt | 4 ++-- .../css/recovery-cacheable/__snapshots__/web/2.snap.txt | 4 ++-- .../hotCases/css/recovery/__snapshots__/web/2.snap.txt | 4 ++-- .../hotCases/css/vanilla/__snapshots__/web/1.snap.txt | 4 ++-- .../import-order-change/__snapshots__/web/1.snap.txt | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/rspack-test/hotCases/css/css-loading-unique-name/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/css/css-loading-unique-name/__snapshots__/web/1.snap.txt index 2d3f7f9940f7..18739651fdee 100644 --- a/tests/rspack-test/hotCases/css/css-loading-unique-name/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/css/css-loading-unique-name/__snapshots__/web/1.snap.txt @@ -5,7 +5,7 @@ ## Asset Files - Bundle: bundle.js -- Manifest: main.LAST_HASH.hot-update.json, size: 56 +- Manifest: main.LAST_HASH.hot-update.json, size: 28 - Update: main.LAST_HASH.hot-update.js, size: 331 ## Manifest @@ -13,7 +13,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main"],"r":[],"m":[],"css":{"main":"bundle.css"}} +{"c":["main"],"r":[],"m":[]} ``` diff --git a/tests/rspack-test/hotCases/css/css-modules/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/css/css-modules/__snapshots__/web/1.snap.txt index ac90bfbb3fdb..b75e17851d74 100644 --- a/tests/rspack-test/hotCases/css/css-modules/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/css/css-modules/__snapshots__/web/1.snap.txt @@ -7,7 +7,7 @@ ## Asset Files - Bundle: bundle.js - Bundle: style2_module_css.chunk.CURRENT_HASH.js -- Manifest: main.LAST_HASH.hot-update.json, size: 143 +- Manifest: main.LAST_HASH.hot-update.json, size: 48 - Update: main.LAST_HASH.hot-update.js, size: 708 - Update: style2_module_css.LAST_HASH.hot-update.js, size: 576 @@ -16,7 +16,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main","style2_module_css"],"r":[],"m":[],"css":{"style2_module_css":"style2_module_css.chunk.CURRENT_HASH.css","main":"bundle.css"}} +{"c":["main","style2_module_css"],"r":[],"m":[]} ``` diff --git a/tests/rspack-test/hotCases/css/fetch-priority/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/css/fetch-priority/__snapshots__/web/1.snap.txt index 8bdb722df0cb..f90e067d1571 100644 --- a/tests/rspack-test/hotCases/css/fetch-priority/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/css/fetch-priority/__snapshots__/web/1.snap.txt @@ -6,7 +6,7 @@ ## Asset Files - Bundle: bundle.js - Bundle: style_module_css.chunk.CURRENT_HASH.js -- Manifest: main.LAST_HASH.hot-update.json, size: 120 +- Manifest: main.LAST_HASH.hot-update.json, size: 47 - Update: main.LAST_HASH.hot-update.js, size: 181 - Update: style_module_css.LAST_HASH.hot-update.js, size: 589 @@ -15,7 +15,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main","style_module_css"],"r":[],"m":[],"css":{"style_module_css":"style_module_css.chunk.CURRENT_HASH.css"}} +{"c":["main","style_module_css"],"r":[],"m":[]} ``` diff --git a/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt b/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt index 14923d5d0e02..0ba66aa4485b 100644 --- a/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt +++ b/tests/rspack-test/hotCases/css/recovery-cacheable/__snapshots__/web/2.snap.txt @@ -5,7 +5,7 @@ ## Asset Files - Bundle: bundle.js -- Manifest: main.LAST_HASH.hot-update.json, size: 54 +- Manifest: main.LAST_HASH.hot-update.json, size: 58 - Update: main.hot-update.js, size: 993 ## Manifest @@ -13,7 +13,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main"],"r":[],"m":[],"css":{"main":"main.css"}} +{"c":["main"],"r":[],"m":[],"miniCss":{"main":"main.css"}} ``` diff --git a/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt b/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt index 7b37d8346324..73c8d52dc541 100644 --- a/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt +++ b/tests/rspack-test/hotCases/css/recovery/__snapshots__/web/2.snap.txt @@ -5,7 +5,7 @@ ## Asset Files - Bundle: bundle.js -- Manifest: main.LAST_HASH.hot-update.json, size: 54 +- Manifest: main.LAST_HASH.hot-update.json, size: 58 - Update: main.hot-update.js, size: 6038 ## Manifest @@ -13,7 +13,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main"],"r":[],"m":[],"css":{"main":"main.css"}} +{"c":["main"],"r":[],"m":[],"miniCss":{"main":"main.css"}} ``` diff --git a/tests/rspack-test/hotCases/css/vanilla/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/css/vanilla/__snapshots__/web/1.snap.txt index c4fc2958d247..8ee7497fb818 100644 --- a/tests/rspack-test/hotCases/css/vanilla/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/css/vanilla/__snapshots__/web/1.snap.txt @@ -7,7 +7,7 @@ ## Asset Files - Bundle: bundle.js - Bundle: style2_css.chunk.CURRENT_HASH.js -- Manifest: main.LAST_HASH.hot-update.json, size: 99 +- Manifest: main.LAST_HASH.hot-update.json, size: 41 - Update: main.LAST_HASH.hot-update.js, size: 181 - Update: style2_css.LAST_HASH.hot-update.js, size: 199 @@ -16,7 +16,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main","style2_css"],"r":[],"m":[],"css":{"main":"bundle.css","style2_css":"style2_css.css"}} +{"c":["main","style2_css"],"r":[],"m":[]} ``` diff --git a/tests/rspack-test/hotCases/module-graph/import-order-change/__snapshots__/web/1.snap.txt b/tests/rspack-test/hotCases/module-graph/import-order-change/__snapshots__/web/1.snap.txt index 2013f1e14411..88aaa77cfd1d 100644 --- a/tests/rspack-test/hotCases/module-graph/import-order-change/__snapshots__/web/1.snap.txt +++ b/tests/rspack-test/hotCases/module-graph/import-order-change/__snapshots__/web/1.snap.txt @@ -5,7 +5,7 @@ ## Asset Files - Bundle: bundle.js -- Manifest: main.LAST_HASH.hot-update.json, size: 56 +- Manifest: main.LAST_HASH.hot-update.json, size: 60 - Update: main.LAST_HASH.hot-update.js, size: 466 ## Manifest @@ -13,7 +13,7 @@ ### main.LAST_HASH.hot-update.json ```json -{"c":["main"],"r":[],"m":[],"css":{"main":"bundle.css"}} +{"c":["main"],"r":[],"m":[],"miniCss":{"main":"bundle.css"}} ``` From ee2c0f197b0446fd42c76b083ad4d0ca96982d8f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:45:59 +0000 Subject: [PATCH 6/8] fix(css-extract): propagate weak requirement for HMR_MINI_CSS_FILENAMES 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. --- .../rspack_plugin_extract_css/src/runtime.rs | 10 ++++++++-- .../css-extract/with-contenthash/index.js | 7 +++++-- .../index.js | 14 ++++++++++++++ .../lazy.css | 3 +++ .../rspack.config.js | 19 +++++++++++++++++++ .../test.config.js | 3 +++ 6 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/index.js create mode 100644 tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/lazy.css create mode 100644 tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/rspack.config.js create mode 100644 tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/test.config.js diff --git a/crates/rspack_plugin_extract_css/src/runtime.rs b/crates/rspack_plugin_extract_css/src/runtime.rs index c06b0ae1553d..6f7c9305982c 100644 --- a/crates/rspack_plugin_extract_css/src/runtime.rs +++ b/crates/rspack_plugin_extract_css/src/runtime.rs @@ -161,14 +161,20 @@ impl RuntimeModule for CssLoadingRuntimeModule { CSS_LOADING_BASIC_RUNTIME_REQUIREMENTS.dependencies | CSS_LOADING_WITH_LOADING_RUNTIME_REQUIREMENTS.dependencies, ); - weak.insert(CSS_LOADING_CREATE_LINK_RUNTIME_REQUIREMENTS.weak); + weak.insert( + CSS_LOADING_BASIC_RUNTIME_REQUIREMENTS.weak + | CSS_LOADING_CREATE_LINK_RUNTIME_REQUIREMENTS.weak, + ); } if runtime_requirements.contains(RuntimeGlobals::HMR_DOWNLOAD_UPDATE_HANDLERS) { dependencies.insert( CSS_LOADING_BASIC_RUNTIME_REQUIREMENTS.dependencies | CSS_LOADING_WITH_HMR_RUNTIME_REQUIREMENTS.dependencies, ); - weak.insert(CSS_LOADING_CREATE_LINK_RUNTIME_REQUIREMENTS.weak); + weak.insert( + CSS_LOADING_BASIC_RUNTIME_REQUIREMENTS.weak + | CSS_LOADING_CREATE_LINK_RUNTIME_REQUIREMENTS.weak, + ); } if runtime_requirements.contains(RuntimeGlobals::PREFETCH_CHUNK_HANDLERS) { let requirements = *CSS_LOADING_WITH_PREFETCH_RUNTIME_REQUIREMENTS; diff --git a/tests/rspack-test/configCases/css-extract/with-contenthash/index.js b/tests/rspack-test/configCases/css-extract/with-contenthash/index.js index 80b8dd51a4dc..90211246e63a 100644 --- a/tests/rspack-test/configCases/css-extract/with-contenthash/index.js +++ b/tests/rspack-test/configCases/css-extract/with-contenthash/index.js @@ -2,6 +2,9 @@ it("should not contain full hash runtime module", async () => { await import("./index.css"); const chunk = require("fs").readFileSync(__filename, "utf-8"); - const hashRuntime = ["__webpack_require__", "h"].join(".") // use join() here to avoid compile time evaluation - expect(chunk).not.toContain(hashRuntime); + // Match the full hash accessor itself, not any longer property name that happens to + // start with the same letter (e.g. the extract-css HMR filename map) - a plain substring + // check would false-positive on those too. + const hashRuntime = new RegExp(["__webpack_require__", "h"].join("\\.") + "\\b") // use join() here to avoid compile time evaluation + expect(chunk).not.toMatch(hashRuntime); }); diff --git a/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/index.js b/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/index.js new file mode 100644 index 000000000000..4ca654e83efe --- /dev/null +++ b/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/index.js @@ -0,0 +1,14 @@ +it("should load an async extracted css chunk in rspack runtime mode without HMR", async () => { + // Regression test: extractCssLoadStylesheet weakly reads HMR_MINI_CSS_FILENAMES so it + // can prefer a fresh HMR-updated filename over the chunk filename function. That weak + // read must still force a (possibly unused) lexical declaration of the global under + // runtimeMode: "rspack", or referencing it here - with no HMR runtime module present at + // all - throws a ReferenceError instead of just falling back to the chunk filename. + await import(/* webpackChunkName: "lazy" */ "./lazy.css"); + + const link = document.head._children.find(function (child) { + return child._type === "link"; + }); + expect(link).toBeTruthy(); + expect(link.href).toContain("lazy.css"); +}); diff --git a/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/lazy.css b/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/lazy.css new file mode 100644 index 000000000000..36505138bc99 --- /dev/null +++ b/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/lazy.css @@ -0,0 +1,3 @@ +body { + color: blue; +} diff --git a/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/rspack.config.js b/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/rspack.config.js new file mode 100644 index 000000000000..2b7a324085dd --- /dev/null +++ b/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/rspack.config.js @@ -0,0 +1,19 @@ +const { CssExtractRspackPlugin } = require('@rspack/core'); + +/** @type {import("@rspack/core").Configuration} */ +module.exports = { + target: 'web', + experiments: { + runtimeMode: 'rspack', + }, + module: { + rules: [ + { + test: /\.css$/, + use: [CssExtractRspackPlugin.loader, 'css-loader'], + type: 'javascript/auto', + }, + ], + }, + plugins: [new CssExtractRspackPlugin()], +}; diff --git a/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/test.config.js b/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/test.config.js new file mode 100644 index 000000000000..114482267044 --- /dev/null +++ b/tests/rspack-test/configCases/runtime/runtime-mode-css-extract-async-chunk/test.config.js @@ -0,0 +1,3 @@ +module.exports = { + documentType: "jsdom" +}; From dadeede52be2cb364dfbf9f1b9fe0ce80b31ed20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 17:26:09 +0000 Subject: [PATCH 7/8] fix(css-extract): prune stale retained CSS filenames on chunk removal/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 Claude-Session: https://claude.ai/code/session_01AEYqP3tNtaUW3RarNrSwLw --- .../src/runtime/hot_module_replacement.ejs | 18 ++++++++++++++++++ .../contenthash-hmr-lazy-chunk/index.test.ts | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs b/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs index e6173502f1b1..2f0ac33ba87c 100644 --- a/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs +++ b/crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs @@ -272,6 +272,24 @@ function hotCheck(applyOnUpdate) { currentUpdateMiniCssFilenames[cssChunkId] = update.miniCss[cssChunkId]; } } + // Prune retained filenames that have gone stale, so a later first-time import() + // of such a chunk resolves miniCssF (or nothing) rather than preferring a href the + // dev server no longer holds - a guaranteed 404. Two cases: + // - a chunk in update.c but absent from update.miniCss was rebuilt yet now carries + // no extract-css asset (its CSS import was removed), so any retained href is dead; + // - a chunk in update.r was removed outright. + if (update.c) { + update.c.forEach(function (updatedChunkId) { + if (!(update.miniCss && updatedChunkId in update.miniCss)) { + delete currentUpdateMiniCssFilenames[updatedChunkId]; + } + }); + } + if (update.r) { + update.r.forEach(function (removedChunkId) { + delete currentUpdateMiniCssFilenames[removedChunkId]; + }); + } return Promise.all( Object.keys(<%- HMR_DOWNLOAD_UPDATE_HANDLERS %>).reduce(function ( diff --git a/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/index.test.ts b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/index.test.ts index 5de6df44e3fa..1a3d1ed9e798 100644 --- a/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/index.test.ts +++ b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/index.test.ts @@ -12,6 +12,14 @@ test('should apply a CSS edit made before the chunk owning it is ever imported', page, fileAction, }) => { + // Unlike the sibling contenthash-hmr case (which mutates an already-loaded + // stylesheet and lets toHaveCSS retry), this one must wait for the update to be fully + // *applied* before triggering the first import(): if #load runs while the retained + // filename is still stale, the chunk loads the pre-edit href and the failure is + // permanent (the chunk is marked errored and never re-fetched). "[HMR] App is up to + // date." is emitted by this repo's own hot client (packages/rspack/hot/dev-server.js) + // exactly on apply completion, so it's the correct - and repo-owned, not third-party - + // signal to gate on. const hmrSettled = page.waitForEvent('console', { predicate: (msg) => msg.text().includes('up to date'), }); From 5ba9d24a365e01d5087743c131574bb65bccc1f2 Mon Sep 17 00:00:00 2001 From: Sergey Gultyayev Date: Fri, 17 Jul 2026 08:39:35 +0200 Subject: [PATCH 8/8] test: regenerate import-context-css esm snapshots for new css loading runtime --- .../__snapshots__/esm.snap.txt | 25 +++++++++++++++-- .../runtimeModeSnapshot/esm.snap.txt | 27 ++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/tests/rspack-test/esmOutputCases/dynamic-import/import-context-css/__snapshots__/esm.snap.txt b/tests/rspack-test/esmOutputCases/dynamic-import/import-context-css/__snapshots__/esm.snap.txt index 87b4c2670d75..9b68bb1622c2 100644 --- a/tests/rspack-test/esmOutputCases/dynamic-import/import-context-css/__snapshots__/esm.snap.txt +++ b/tests/rspack-test/esmOutputCases/dynamic-import/import-context-css/__snapshots__/esm.snap.txt @@ -208,11 +208,18 @@ __webpack_require__.p = scriptUrl; // webpack/runtime/css loading (() => { if (typeof document === "undefined") return; +// Scopes the chunk-identity attribute used to find a currently installed stylesheet +// (see extractCssFindStylesheet) to this compilation, so two independently compiled +// apps sharing a chunk id on the same page can't steal or remove each other's +// stylesheet. Matches the same output.uniqueName-based identity scheme chunk/module +// script loading already uses (see load_script.ejs). +var extractCssUniquePrefix = ""; var extractCssCreateStylesheet = function ( chunkId, fullhref, oldTag, resolve, reject, fetchPriority ) { var linkTag = document.createElement("link"); +linkTag.setAttribute("data-webpack-chunk", extractCssUniquePrefix + chunkId); linkTag.rel = "stylesheet"; linkTag.type = "text/css"; @@ -252,10 +259,16 @@ if (fetchPriority) { } return linkTag; } -var extractCssFindStylesheet = function (href, fullhref) { +var extractCssFindStylesheet = function (href, fullhref, chunkId) { var existingLinkTags = document.getElementsByTagName("link"); for (var i = 0; i < existingLinkTags.length; i++) { var tag = existingLinkTags[i]; + // When a chunkId is given (HMR replacement lookup), prefer matching the tag that + // was created for that chunk: the href it was created with may since have changed + // (e.g. a hashed filename), so it can no longer be found by comparing hrefs. + if (chunkId !== undefined && tag.getAttribute("data-webpack-chunk") === extractCssUniquePrefix + String(chunkId)) { + return tag; + } var dataHref = tag.getAttribute("data-href") || tag.getAttribute("href"); if (dataHref) { dataHref = dataHref.split('?')[0] @@ -273,7 +286,15 @@ var extractCssFindStylesheet = function (href, fullhref) { var extractCssLoadStylesheet = function (chunkId, fetchPriority) { return new Promise(function (resolve, reject) { - var href = __webpack_require__.miniCssF(chunkId); + /** + A chunk that hasn't been loaded yet (e.g. a lazy route not imported so far) has + no tag for the HMR handler to update, so it instead just retains the fresh + filename it learned about (see HMR_MINI_CSS_FILENAMES in the with-hmr runtime). + Consult that retained mapping here too, or loading the chunk for the first time + after its CSS changed would still request the stale, pre-edit filename. + */ + var miniCssFilenames = __webpack_require__.hmrMCF; + var href = (miniCssFilenames && miniCssFilenames[chunkId]) || __webpack_require__.miniCssF(chunkId); var fullhref = __webpack_require__.p + href; if (extractCssFindStylesheet(href, fullhref)) return resolve(); extractCssCreateStylesheet(chunkId, fullhref, null, resolve, reject, fetchPriority); diff --git a/tests/rspack-test/esmOutputCases/dynamic-import/import-context-css/__snapshots__/runtimeModeSnapshot/esm.snap.txt b/tests/rspack-test/esmOutputCases/dynamic-import/import-context-css/__snapshots__/runtimeModeSnapshot/esm.snap.txt index dca176d0ea54..8404d1fd408d 100644 --- a/tests/rspack-test/esmOutputCases/dynamic-import/import-context-css/__snapshots__/runtimeModeSnapshot/esm.snap.txt +++ b/tests/rspack-test/esmOutputCases/dynamic-import/import-context-css/__snapshots__/runtimeModeSnapshot/esm.snap.txt @@ -129,7 +129,7 @@ __rspack_context.r = __rspack_require; // expose the modules object (__rspack_modules) __rspack_context.m = __rspack_modules; -var publicPath, moduleFactories=__rspack_modules, scriptNonce; +var publicPath, moduleFactories=__rspack_modules, scriptNonce, hmrMiniCssFilenames; // rspack/runtime/define_property_getters var definePropertyGetters = (exports, getters, values) => { var define = (defs, kind) => { @@ -200,11 +200,18 @@ __rspack_context.p = publicPath; // rspack/runtime/css loading (() => { if (typeof document === "undefined") return; +// Scopes the chunk-identity attribute used to find a currently installed stylesheet +// (see extractCssFindStylesheet) to this compilation, so two independently compiled +// apps sharing a chunk id on the same page can't steal or remove each other's +// stylesheet. Matches the same output.uniqueName-based identity scheme chunk/module +// script loading already uses (see load_script.ejs). +var extractCssUniquePrefix = ""; var extractCssCreateStylesheet = function ( chunkId, fullhref, oldTag, resolve, reject, fetchPriority ) { var linkTag = document.createElement("link"); +linkTag.setAttribute("data-webpack-chunk", extractCssUniquePrefix + chunkId); linkTag.rel = "stylesheet"; linkTag.type = "text/css"; @@ -244,10 +251,16 @@ if (fetchPriority) { } return linkTag; } -var extractCssFindStylesheet = function (href, fullhref) { +var extractCssFindStylesheet = function (href, fullhref, chunkId) { var existingLinkTags = document.getElementsByTagName("link"); for (var i = 0; i < existingLinkTags.length; i++) { var tag = existingLinkTags[i]; + // When a chunkId is given (HMR replacement lookup), prefer matching the tag that + // was created for that chunk: the href it was created with may since have changed + // (e.g. a hashed filename), so it can no longer be found by comparing hrefs. + if (chunkId !== undefined && tag.getAttribute("data-webpack-chunk") === extractCssUniquePrefix + String(chunkId)) { + return tag; + } var dataHref = tag.getAttribute("data-href") || tag.getAttribute("href"); if (dataHref) { dataHref = dataHref.split('?')[0] @@ -265,7 +278,15 @@ var extractCssFindStylesheet = function (href, fullhref) { var extractCssLoadStylesheet = function (chunkId, fetchPriority) { return new Promise(function (resolve, reject) { - var href = __rspack_context.miniCssF(chunkId); + /** + A chunk that hasn't been loaded yet (e.g. a lazy route not imported so far) has + no tag for the HMR handler to update, so it instead just retains the fresh + filename it learned about (see HMR_MINI_CSS_FILENAMES in the with-hmr runtime). + Consult that retained mapping here too, or loading the chunk for the first time + after its CSS changed would still request the stale, pre-edit filename. + */ + var miniCssFilenames = hmrMiniCssFilenames; + var href = (miniCssFilenames && miniCssFilenames[chunkId]) || __rspack_context.miniCssF(chunkId); var fullhref = publicPath + href; if (extractCssFindStylesheet(href, fullhref)) return resolve(); extractCssCreateStylesheet(chunkId, fullhref, null, resolve, reject, fetchPriority);