Skip to content

Commit e3ba2ff

Browse files
fix: emit <link rel=stylesheet> for CSS chunks reachable from <script src> in HTML entries (#21002)
1 parent 1097a7f commit e3ba2ff

40 files changed

Lines changed: 916 additions & 25 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
HTML-entry pipeline (`experiments.html` + `experiments.css`): emit `<link rel="stylesheet">` tags for CSS chunks reachable from a `<script src>` entry. Previously when the bundled JS imported CSS, the resulting `.css` file was emitted to disk but never referenced from the extracted HTML (no `<link>` tag), and when `splitChunks` extracted CSS into sibling chunks the HTML cloned the originating `<script>` for each one — producing `<script src="style.js">` pointing at non-existent JS filenames instead of `<link rel="stylesheet" href="style.css">`. CSS chunks are now sorted by the entrypoint's module post-order index so the `<link>` tags also appear in source import order, fixing the cascade ordering issue documented in `html-webpack-plugin#1838` and `webpack/mini-css-extract-plugin#959` for HTML-entry builds. `nonce`/`crossorigin`/`referrerpolicy` are copied from the originating tag onto the emitted `<link>`.

lib/dependencies/HtmlScriptSrcDependency.js

Lines changed: 264 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@
44

55
"use strict";
66

7+
const {
8+
CSS_IMPORT_TYPE,
9+
CSS_TYPE,
10+
JAVASCRIPT_TYPE
11+
} = require("../ModuleSourceTypeConstants");
712
const makeSerializable = require("../util/makeSerializable");
813
const CssUrlDependency = require("./CssUrlDependency");
914
const ModuleDependency = require("./ModuleDependency");
1015

1116
/** @typedef {import("webpack-sources").ReplaceSource} ReplaceSource */
1217
/** @typedef {import("../Chunk")} Chunk */
18+
/** @typedef {import("../ChunkGraph")} ChunkGraph */
1319
/** @typedef {import("../Dependency")} Dependency */
1420
/** @typedef {import("../DependencyTemplate").DependencyTemplateContext} DependencyTemplateContext */
1521
/** @typedef {import("../Entrypoint")} Entrypoint */
@@ -184,6 +190,144 @@ const getEntrypointChunksInLoadOrder = (entrypoint) => {
184190
return ordered;
185191
};
186192

193+
/**
194+
* Whether webpack will emit a `.js` file for this chunk that must be
195+
* loaded with a `<script>` tag. Covers three independent reasons a
196+
* chunk needs JS output: it owns one or more JS-source-type modules;
197+
* it has entry modules whose source types include JavaScript (entry
198+
* modules don't show up in `getChunkModulesIterableBySourceType` until
199+
* they're connected as regular modules — this is why
200+
* `JavascriptModulesPlugin#_chunkHasJs` checks them separately); or it
201+
* is a runtime chunk — `chunk.hasRuntime()` — which produces a `.js`
202+
* file holding the webpack runtime, but its `RuntimeModule`s live in
203+
* a separate `runtimeModules` set and are *not* surfaced via
204+
* `getChunkModulesIterableBySourceType`. Missing the runtime case
205+
* would cause a `runtimeChunk`-split chunk to fall out of the
206+
* `<script>` list and re-emerge after the chunks that depend on it,
207+
* producing `__webpack_require__ is not defined` at load time.
208+
* @param {Chunk} chunk chunk
209+
* @param {ChunkGraph} chunkGraph chunk graph
210+
* @returns {boolean} true if the chunk emits a `.js` file
211+
*/
212+
const chunkHasJs = (chunk, chunkGraph) => {
213+
if (chunk.hasRuntime()) return true;
214+
if (chunkGraph.getNumberOfEntryModules(chunk) > 0) {
215+
for (const module of chunkGraph.getChunkEntryModulesIterable(chunk)) {
216+
if (chunkGraph.getModuleSourceTypes(module).has(JAVASCRIPT_TYPE)) {
217+
return true;
218+
}
219+
}
220+
}
221+
return Boolean(
222+
chunkGraph.getChunkModulesIterableBySourceType(chunk, JAVASCRIPT_TYPE)
223+
);
224+
};
225+
226+
/**
227+
* Whether webpack will emit a `.css` file for this chunk that must be
228+
* loaded with a `<link rel="stylesheet">` tag. Matches
229+
* `CssModulesPlugin.chunkHasCss` exactly — both regular CSS modules
230+
* and pure `@import` placeholder modules count, since the latter
231+
* still contribute a `.css` asset to the chunk.
232+
* @param {Chunk} chunk chunk
233+
* @param {ChunkGraph} chunkGraph chunk graph
234+
* @returns {boolean} true if the chunk emits a `.css` file
235+
*/
236+
const chunkHasCss = (chunk, chunkGraph) =>
237+
Boolean(chunkGraph.getChunkModulesIterableBySourceType(chunk, CSS_TYPE)) ||
238+
Boolean(
239+
chunkGraph.getChunkModulesIterableBySourceType(chunk, CSS_IMPORT_TYPE)
240+
);
241+
242+
/**
243+
* Compare two chunks for a deterministic tie-break in CSS link ordering.
244+
* `chunk.name` and `chunk.id` are both stable strings (when present);
245+
* one of them is set for every chunk webpack emits. We can't rely on
246+
* `Array.prototype.sort` being stable — webpack still supports Node
247+
* 10.13 where V8's sort is not guaranteed stable for arrays larger
248+
* than ten elements — so any time `firstCssModulePostOrderIndex`
249+
* returns the same value for two chunks (most commonly when several
250+
* chunks have no reachable CSS module in the entrypoint's dependency
251+
* walk and all map to `Infinity`) this comparator picks the canonical
252+
* order.
253+
* @param {Chunk} a first chunk
254+
* @param {Chunk} b second chunk
255+
* @returns {-1 | 0 | 1} ordering
256+
*/
257+
const compareChunksForCssTieBreak = (a, b) => {
258+
const an = `${a.name || ""} ${a.id === null || a.id === undefined ? "" : a.id}`;
259+
const bn = `${b.name || ""} ${b.id === null || b.id === undefined ? "" : b.id}`;
260+
if (an < bn) return -1;
261+
if (an > bn) return 1;
262+
return 0;
263+
};
264+
265+
/**
266+
* Smallest post-order index among the CSS modules of a chunk, taken
267+
* from the entrypoint's view of the dependency graph. Used to sort
268+
* sibling CSS chunks so they appear in source import order in the
269+
* extracted HTML — `entrypoint.chunks` itself does not give that
270+
* ordering for arbitrary splitChunks layouts. Considers both
271+
* `CSS_TYPE` and `CSS_IMPORT_TYPE` modules so a chunk made up
272+
* exclusively of `@import` placeholder modules (e.g. when splitChunks
273+
* separates them from their target CSS) still sorts by its true
274+
* source position rather than collapsing to `Infinity` and relying on
275+
* the chunk-name tie-breaker.
276+
* @param {Chunk} chunk chunk
277+
* @param {Entrypoint} entrypoint entrypoint the chunk belongs to
278+
* @param {ChunkGraph} chunkGraph chunk graph
279+
* @returns {number} the lowest post-order index of any CSS or
280+
* CSS-import module in the chunk, or `Number.POSITIVE_INFINITY` when
281+
* no such module has a defined index (e.g. for a module the
282+
* entrypoint never reached on its own dependency walk — runtime-only
283+
* modules, modules reached via `dependOn`, etc.) so such chunks sort
284+
* last among CSS chunks
285+
*/
286+
const firstCssModulePostOrderIndex = (chunk, entrypoint, chunkGraph) => {
287+
let min = Number.POSITIVE_INFINITY;
288+
for (const sourceType of [CSS_TYPE, CSS_IMPORT_TYPE]) {
289+
const modules = chunkGraph.getChunkModulesIterableBySourceType(
290+
chunk,
291+
sourceType
292+
);
293+
if (!modules) continue;
294+
for (const module of modules) {
295+
const idx = entrypoint.getModulePostOrderIndex(module);
296+
if (idx !== undefined && idx < min) min = idx;
297+
}
298+
}
299+
return min;
300+
};
301+
302+
const COPYABLE_LINK_ATTRS = ["nonce", "crossorigin", "referrerpolicy"];
303+
304+
/**
305+
* Build a fresh `<link rel="stylesheet" href="…">` for a CSS chunk that
306+
* was pulled in by a `<script src>` entry — the originating tag was a
307+
* `<script>`, but the chunk is CSS so cloning the script tag verbatim
308+
* would produce nonsense (`<script src="…\.css">`). Copy
309+
* `nonce`/`crossorigin`/`referrerpolicy` from the original element so
310+
* the same CSP and fetch policy applies; `defer`/`async`/`type` have no
311+
* meaning on `<link>` and are dropped.
312+
* @param {string} originalTag the originating `<script>`/`<link>` tag's source text
313+
* @param {string} href URL for the stylesheet
314+
* @returns {string} the sibling `<link>` tag's HTML
315+
*/
316+
const buildStylesheetLink = (originalTag, href) => {
317+
let extra = "";
318+
for (const attr of COPYABLE_LINK_ATTRS) {
319+
// Match ` <attr>`, ` <attr>=value`, ` <attr>="value"`, ` <attr>='value'`.
320+
const re = new RegExp(
321+
`\\s${attr}(?:\\s*=\\s*(?:"[^"]*"|'[^']*'|[^\\s>]+))?(?=[\\s/>])`,
322+
"i"
323+
);
324+
const m = originalTag.match(re);
325+
if (m) extra += m[0];
326+
}
327+
const safeHref = href.replace(/"/g, "&quot;");
328+
return `<link rel="stylesheet" href="${safeHref}"${extra}>`;
329+
};
330+
187331
/**
188332
* Clone the original `<script>`/`<link>` opening tag with its `src`/`href`
189333
* value swapped for a different chunk URL. Reusing the source text verbatim
@@ -252,6 +396,7 @@ HtmlScriptSrcDependency.Template = class HtmlScriptSrcDependencyTemplate extends
252396
const { runtimeTemplate } = templateContext;
253397
const dep = /** @type {HtmlScriptSrcDependency} */ (dependency);
254398
const compilation = runtimeTemplate.compilation;
399+
const { chunkGraph } = compilation;
255400
const entrypoint = /** @type {Entrypoint | undefined} */ (
256401
compilation.entrypoints.get(dep.entryName)
257402
);
@@ -263,50 +408,144 @@ HtmlScriptSrcDependency.Template = class HtmlScriptSrcDependencyTemplate extends
263408

264409
const orderedChunks = getEntrypointChunksInLoadOrder(entrypoint);
265410
const entryChunk = orderedChunks[orderedChunks.length - 1];
266-
const contentHashType =
267-
dep.elementKind === "stylesheet" ? "css" : "javascript";
411+
const isStylesheet = dep.elementKind === "stylesheet";
412+
413+
// Rewrite the originating tag's src/href to the entry chunk's
414+
// primary asset for that element kind: `.css` for
415+
// `<link rel="stylesheet">`, `.js` for everything else.
416+
const entryContentHashType = isStylesheet ? "css" : "javascript";
268417
const entryUrl = `${CssUrlDependency.PUBLIC_PATH_AUTO}${getChunkFilename(
269418
entryChunk,
270419
compilation,
271-
contentHashType
420+
entryContentHashType
272421
)}`;
273422
source.replace(dep.range[0], dep.range[1] - 1, entryUrl);
274423

275-
if (
276-
orderedChunks.length <= 1 ||
277-
dep.tagStart < 0 ||
278-
dep.tagOpenEnd <= dep.tagStart
279-
) {
424+
if (dep.tagStart < 0 || dep.tagOpenEnd <= dep.tagStart) {
280425
return;
281426
}
282427

283-
// The browser must load every chunk in dependency order, not just the
284-
// entry chunk. Clone the original tag for each non-entry chunk so the
285-
// preserved attributes (nonce, crossorigin, …) match the entry tag,
286-
// and insert the clones before the original tag's `<`.
428+
// The browser must load every chunk the entry needs, not just the
429+
// entry chunk. For `<script>` entries that's the JS for sibling
430+
// chunks plus — critically — the CSS for any chunk that holds
431+
// stylesheets imported transitively from the JS source. Previously
432+
// every sibling was cloned as a `<script>` pointing at a `.js`
433+
// filename, so CSS chunks ended up as `<script src="foo.css">`
434+
// pointing at non-existent `.js` files (the bug in
435+
// html-webpack-plugin#1838 / webpack/mini-css-extract-plugin#959,
436+
// magnified here because the entry chunk's own CSS was emitted to
437+
// disk but never linked from the HTML at all).
287438
const originalContent = /** @type {string} */ (source.original().source());
288439
const originalTag = originalContent.slice(dep.tagStart, dep.tagOpenEnd);
289440
const srcStartInTag = dep.range[0] - dep.tagStart;
290441
const srcEndInTag = dep.range[1] - dep.tagStart;
291442

292-
const siblings = [];
293-
for (let i = 0; i < orderedChunks.length - 1; i++) {
443+
/**
444+
* @param {Chunk} chunk chunk to emit a sibling tag for
445+
* @param {"javascript" | "css"} kind content type slice of the chunk to emit
446+
* @returns {string} a single sibling tag's HTML
447+
*/
448+
const buildSibling = (chunk, kind) => {
294449
const url = `${CssUrlDependency.PUBLIC_PATH_AUTO}${getChunkFilename(
295-
orderedChunks[i],
450+
chunk,
296451
compilation,
297-
contentHashType
452+
kind
298453
)}`;
299-
siblings.push(
300-
cloneTagWithUrl(
301-
originalTag,
302-
srcStartInTag,
303-
srcEndInTag,
304-
url,
305-
dep.elementKind
306-
)
454+
if (kind === "css" && !isStylesheet) {
455+
// Originating tag is `<script>` (or `<link rel=modulepreload>`)
456+
// but this chunk is CSS — emit a fresh `<link>` rather than
457+
// cloning the script.
458+
return buildStylesheetLink(originalTag, url);
459+
}
460+
return cloneTagWithUrl(
461+
originalTag,
462+
srcStartInTag,
463+
srcEndInTag,
464+
url,
465+
dep.elementKind
307466
);
467+
};
468+
469+
const siblings = [];
470+
471+
if (isStylesheet) {
472+
// `<link rel="stylesheet">` entries are CSS-only — every sibling
473+
// chunk in the entrypoint is also CSS. Keep cloning the original
474+
// `<link>` for them so attributes like `media` carry over.
475+
for (let i = 0; i < orderedChunks.length - 1; i++) {
476+
siblings.push(buildSibling(orderedChunks[i], "css"));
477+
}
478+
} else {
479+
// CSS chunks are emitted before JS chunks so the cascade is set
480+
// up before any script runs. Within CSS the order needs to match
481+
// the source's import order — `entrypoint.chunks` alone doesn't
482+
// give us that for arbitrary splitChunks layouts (splitChunks
483+
// inserts each new chunk before the entry chunk via
484+
// `insertChunk(_, before)`, so split CSS siblings end up in
485+
// *reverse* of the order they were processed — exactly the
486+
// html-webpack-plugin#1838 / mini-css-extract#959 symptom). We
487+
// re-derive the order from the entrypoint's module post-order
488+
// index, which mirrors the dependency walk and so reflects the
489+
// import order.
490+
/** @type {{ chunk: Chunk, index: number }[]} */
491+
const cssChunkOrder = [];
492+
/** @type {Chunk[]} */
493+
const jsChunks = [];
494+
for (let i = 0; i < orderedChunks.length - 1; i++) {
495+
const chunk = orderedChunks[i];
496+
const hasCss = chunkHasCss(chunk, chunkGraph);
497+
const hasJs = chunkHasJs(chunk, chunkGraph);
498+
if (hasCss) {
499+
cssChunkOrder.push({
500+
chunk,
501+
index: firstCssModulePostOrderIndex(chunk, entrypoint, chunkGraph)
502+
});
503+
}
504+
// Anything that isn't CSS-only stays on the JS lane, in the
505+
// `orderedChunks` order — that preserves the runtime-first /
506+
// vendor-before-entry invariant of `getEntrypointChunksInLoadOrder`.
507+
// Chunks that produce no `.js` and no `.css` (e.g. wasm-only
508+
// or asset-only) still get a `<script>` clone here so we
509+
// keep prior behavior for users who relied on it.
510+
if (hasJs || !hasCss) jsChunks.push(chunk);
511+
}
512+
// If the entry chunk itself contains CSS (entry JS imports CSS
513+
// without splitChunks separating it), fold it into the same CSS
514+
// ordering so the entry-chunk `<link>` lands in the correct
515+
// cascade position relative to sibling CSS chunks.
516+
if (chunkHasCss(entryChunk, chunkGraph)) {
517+
cssChunkOrder.push({
518+
chunk: entryChunk,
519+
index: firstCssModulePostOrderIndex(
520+
entryChunk,
521+
entrypoint,
522+
chunkGraph
523+
)
524+
});
525+
}
526+
cssChunkOrder.sort((a, b) => {
527+
// Direct subtraction would yield `NaN` when both indices are
528+
// `Infinity` (the documented fallback for chunks whose CSS
529+
// modules the entrypoint's walk never reaches), and
530+
// `Array#sort` doesn't promise stable ordering on the legacy
531+
// Node 10 targets this repo still supports — so the
532+
// tie-breaker must always run when the indices match,
533+
// including the `Infinity === Infinity` case.
534+
if (a.index < b.index) return -1;
535+
if (a.index > b.index) return 1;
536+
return compareChunksForCssTieBreak(a.chunk, b.chunk);
537+
});
538+
for (const { chunk } of cssChunkOrder) {
539+
siblings.push(buildSibling(chunk, "css"));
540+
}
541+
for (const chunk of jsChunks) {
542+
siblings.push(buildSibling(chunk, "javascript"));
543+
}
544+
}
545+
546+
if (siblings.length > 0) {
547+
source.insert(dep.tagStart, siblings.join(""));
308548
}
309-
source.insert(dep.tagStart, siblings.join(""));
310549
}
311550
};
312551

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`ConfigCacheTestCases html css-imported-from-js exported tests should emit a <link rel=stylesheet> for the entry chunk's CSS file, copying CSP/fetch attributes from the originating <script> 1`] = `
4+
"<!DOCTYPE html>
5+
<html>
6+
<head>
7+
<title>HTML entry with JS that imports CSS</title>
8+
<link rel=\\"stylesheet\\" href=\\"__html_cbe5b59d_0.css\\" nonce=\\"test-nonce\\" crossorigin=\\"anonymous\\" referrerpolicy=\\"origin\\"><script src=\\"__html_cbe5b59d_0.chunk.js\\" nonce=\\"test-nonce\\" crossorigin=\\"anonymous\\" referrerpolicy=\\"origin\\" defer integrity=\\"sha384-IGNOREME\\"></script>
9+
</head>
10+
<body><div class=\\"hero\\">Box</div></body>
11+
</html>
12+
"
13+
`;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`ConfigTestCases html css-imported-from-js exported tests should emit a <link rel=stylesheet> for the entry chunk's CSS file, copying CSP/fetch attributes from the originating <script> 1`] = `
4+
"<!DOCTYPE html>
5+
<html>
6+
<head>
7+
<title>HTML entry with JS that imports CSS</title>
8+
<link rel=\\"stylesheet\\" href=\\"__html_cbe5b59d_0.css\\" nonce=\\"test-nonce\\" crossorigin=\\"anonymous\\" referrerpolicy=\\"origin\\"><script src=\\"__html_cbe5b59d_0.chunk.js\\" nonce=\\"test-nonce\\" crossorigin=\\"anonymous\\" referrerpolicy=\\"origin\\" defer integrity=\\"sha384-IGNOREME\\"></script>
9+
</head>
10+
<body><div class=\\"hero\\">Box</div></body>
11+
</html>
12+
"
13+
`;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import "./style.css";
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>HTML entry with JS that imports CSS</title>
5+
<script src="./entry.js" nonce="test-nonce" crossorigin="anonymous" referrerpolicy="origin" defer integrity="sha384-IGNOREME"></script>
6+
</head>
7+
<body><div class="hero">Box</div></body>
8+
</html>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.hero { color: green; }
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"use strict";
2+
3+
module.exports = {
4+
findBundle() {
5+
return ["./test.js"];
6+
}
7+
};

0 commit comments

Comments
 (0)