Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/rspack_core/src/runtime_globals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 20 additions & 2 deletions crates/rspack_plugin_extract_css/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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!({
Expand All @@ -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);"),
Expand Down
24 changes: 22 additions & 2 deletions crates/rspack_plugin_extract_css/src/runtime/css_loading.ejs
Original file line number Diff line number Diff line change
@@ -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
) {
Expand All @@ -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]
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 %>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "") { %>
Expand Down
52 changes: 46 additions & 6 deletions crates/rspack_plugin_hmr/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
}
});
}
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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<ModuleId>,
// 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<ChunkId, String>,
}
28 changes: 28 additions & 0 deletions crates/rspack_plugin_hmr/src/runtime/hot_module_replacement.ejs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
var currentModuleData = {};
var currentUpdateMiniCssFilenames = {};
var hmrInstalledModules = <%- MODULE_CACHE %>;

// module and require creation
Expand All @@ -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) {
Expand Down Expand Up @@ -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 (
Expand Down
37 changes: 37 additions & 0 deletions tests/e2e/cases/css/contenthash-hmr-lazy-chunk/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 <link> 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);
});
34 changes: 34 additions & 0 deletions tests/e2e/cases/css/contenthash-hmr-lazy-chunk/rspack.config.js
Original file line number Diff line number Diff line change
@@ -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'],
},
],
},
};
Loading