diff --git a/crates/rspack_core/src/runtime_globals.rs b/crates/rspack_core/src/runtime_globals.rs index fc6c29aeb31d..b08dd3bd7e55 100644 --- a/crates/rspack_core/src/runtime_globals.rs +++ b/crates/rspack_core/src/runtime_globals.rs @@ -291,6 +291,16 @@ define_runtime_globals! { const HAS_FETCH_PRIORITY; + /** + * 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_MINI_CSS_FILENAMES; + // amd module support const AMD_DEFINE; const AMD_OPTIONS; @@ -449,6 +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_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..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; @@ -272,6 +278,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 +315,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 f605c32816cf..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 ) { @@ -23,10 +29,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") === extractCssUniquePrefix + String(chunkId)) { + return tag; + } var dataHref = tag.getAttribute("data-href") || tag.getAttribute("href"); if (dataHref) { dataHref = dataHref.split('?')[0] @@ -44,7 +56,15 @@ var extractCssFindStylesheet = function (href, fullhref) { 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 7cd766fab47b..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,5 +1,6 @@ var linkTag = document.createElement("link"); <%- _set_attributes %> +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 a3a56562a918..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 @@ -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_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_MINI_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_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 01800f81a42a..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,6 +130,25 @@ 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()); + // 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| { + 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() + }); if let Some(current_chunk) = current_chunk { new_runtime = current_chunk @@ -338,6 +357,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_mini_css_filename { + info + .mini_css_filenames + .insert(chunk_id.clone(), css_filename.clone()); + } } }); } @@ -376,6 +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 + .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. @@ -397,12 +424,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, - }) - .to_string(); + }); + if !content.mini_css_filenames.is_empty() { + manifest["miniCss"] = serde_json::json!(content.mini_css_filenames); + } + let manifest_content = manifest.to_string(); compilation.emit_asset( filename, @@ -497,9 +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, + // 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 a59d89090eb6..2f0ac33ba87c 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 currentUpdateMiniCssFilenames = {}; var hmrInstalledModules = <%- MODULE_CACHE %>; // module and require creation @@ -18,6 +19,7 @@ var currentUpdateApplyHandlers; var queuedInvalidatedModules; <%- define(HMR_MODULE_DATA) %> = currentModuleData; +<%- define(HMR_MINI_CSS_FILENAMES) %> = currentUpdateMiniCssFilenames; <% if (_is_rspack_runtime_mode) { %><%- define(INTERCEPT_MODULE_EXECUTION) %> = []; <% } %> <%- INTERCEPT_MODULE_EXECUTION %>.push(function (options) { @@ -262,6 +264,32 @@ function hotCheck(applyOnUpdate) { return setStatus("prepare").then(function () { var updatedModules = []; currentUpdateApplyHandlers = []; + // 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]; + } + } + // 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 new file mode 100644 index 000000000000..1a3d1ed9e798 --- /dev/null +++ b/tests/e2e/cases/css/contenthash-hmr-lazy-chunk/index.test.ts @@ -0,0 +1,37 @@ +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, +}) => { + // 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'), + }); + + 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'; 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(); 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" +}; 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); 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..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: 28 +- 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":[]} +{"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 c95656f6c6eb..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: 28 +- 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":[]} +{"c":["main"],"r":[],"m":[],"miniCss":{"main":"main.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..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: 28 +- 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":[]} +{"c":["main"],"r":[],"m":[],"miniCss":{"main":"bundle.css"}} ```