Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-css-only-entry-empty-js.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack": patch
---

Fixed an issue where empty JavaScript files were generated for CSS-only entry points. The code now correctly checks if entry modules have JavaScript source types before determining whether to generate a JS file.
9 changes: 3 additions & 6 deletions lib/css/CssGenerator.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ const {
const RuntimeGlobals = require("../RuntimeGlobals");
const Template = require("../Template");
const CssImportDependency = require("../dependencies/CssImportDependency");
const EntryDependency = require("../dependencies/EntryDependency");
const { getUndoPath } = require("../util/identifier");
const memoize = require("../util/memoize");

Expand Down Expand Up @@ -472,11 +471,7 @@ class CssGenerator extends Generator {
const sourceTypes = new Set();
const connections = this._moduleGraph.getIncomingConnections(module);

let isEntryModule = false;
for (const connection of connections) {
if (connection.dependency instanceof EntryDependency) {
isEntryModule = true;
}
if (
exportType === "link" &&
connection.dependency instanceof CssImportDependency
Expand All @@ -503,7 +498,7 @@ class CssGenerator extends Generator {
}
}
if (this._generatesJsOnly(module)) {
if (sourceTypes.has(JAVASCRIPT_TYPE) || isEntryModule) {
if (sourceTypes.has(JAVASCRIPT_TYPE)) {
return JAVASCRIPT_TYPES;
}
return new Set();
Expand Down Expand Up @@ -578,3 +573,5 @@ class CssGenerator extends Generator {
}

module.exports = CssGenerator;

module.exports = CssGenerator;
50 changes: 44 additions & 6 deletions lib/javascript/JavascriptModulesPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,37 +75,75 @@ const JavascriptParser = require("./JavascriptParser");
/** @typedef {import("../util/concatenate").ScopeSet} ScopeSet */
/** @typedef {import("../util/concatenate").UsedNamesInScopeInfo} UsedNamesInScopeInfo */

/** @type {WeakMap<ChunkGraph, WeakMap<Chunk, boolean>>} */
const chunkHasJsCache = new WeakMap();

/**
* @param {Chunk} chunk a chunk
* @param {ChunkGraph} chunkGraph the chunk graph
* @returns {boolean} true, when a JS file is needed for this chunk
*/
const chunkHasJs = (chunk, chunkGraph) => {
if (chunkGraph.getNumberOfEntryModules(chunk) > 0) return true;
const _chunkHasJs = (chunk, chunkGraph) => {
if (chunkGraph.getNumberOfEntryModules(chunk) > 0) {
for (const module of chunkGraph.getChunkEntryModulesIterable(chunk)) {
if (chunkGraph.getModuleSourceTypes(module).has(JAVASCRIPT_TYPE)) {
return true;
}
}
}

return Boolean(
chunkGraph.getChunkModulesIterableBySourceType(chunk, JAVASCRIPT_TYPE)
);
};

/**
* @param {Chunk} chunk a chunk
* @param {ChunkGraph} chunkGraph the chunk graph
* @returns {boolean} true, when a JS file is needed for this chunk
*/
const chunkHasJs = (chunk, chunkGraph) => {
let innerCache = chunkHasJsCache.get(chunkGraph);
if (innerCache === undefined) {
innerCache = new WeakMap();
chunkHasJsCache.set(chunkGraph, innerCache);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to use ChunkGraph as a key here (i.e. two level cache)? I think ChunkGraph is not changing due compilations... in case of multi compiler I think we can move chunkHasJsCache inside class, i.e. this._chunkHasJsCache = new WeakMap

Ideally in future we should reduce count of new WeakMap/new WeakSet/new Map/new Set and etc at the top of file, it takes time for initial loading, yeah, it is not critical, but we should refactor it in future

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two-level WeakMap cache is necessary since each compilation (including HMR) creates a new ChunkGraph instance, so the ChunkGraph key correctly isolates results per compilation.

We cannot move the cache into the class because chunkHasJs is a static method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, we will think how to refactor this to avoid extra initial maps/sets in future


const cachedResult = innerCache.get(chunk);
if (cachedResult !== undefined) {
return cachedResult;
}

const result = _chunkHasJs(chunk, chunkGraph);
innerCache.set(chunk, result);
return result;
};

/**
* @param {Chunk} chunk a chunk
* @param {ChunkGraph} chunkGraph the chunk graph
* @returns {boolean} true, when a JS file is needed for this chunk
*/
const chunkHasRuntimeOrJs = (chunk, chunkGraph) => {
if (chunkHasJs(chunk, chunkGraph)) {
return true;
}

if (
chunkGraph.getChunkModulesIterableBySourceType(
chunk,
WEBPACK_MODULE_TYPE_RUNTIME
)
) {
return true;
for (const chunkGroup of chunk.groupsIterable) {
for (const c of chunkGroup.chunks) {
if (chunkHasJs(c, chunkGraph)) return true;
}
}
return false;
}

return Boolean(
chunkGraph.getChunkModulesIterableBySourceType(chunk, JAVASCRIPT_TYPE)
);
return false;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,13 @@ module.exports = {

switch (i % 4) {
case 0:
return ["test.js", `${i}/runtime~app.${ext}`];
return ["test.js"];
case 1:
return ["test.js", `${i}/app.${ext}`, `${i}/runtime~app.${ext}`];
case 2:
return ["test.js", `${i}/app.${ext}`, `${i}/runtime~app.${ext}`];
return ["test.js"];
case 3:
return [
"test.js",
`${i}/entry1.${ext}`,
`${i}/entry2.${ext}`,
`${i}/runtime~entry1.${ext}`,
`${i}/runtime~entry2.${ext}`
];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now we don't generate extra runtime code for such cases because here we have css entries here, right?

Copy link
Member Author

@xiaoxiaojx xiaoxiaojx Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah — in both of the following entry cases, we no longer generate runtime.js or main.js.

entry: {
					app: ["../_images/file.png", "./entry.css"]
				}

return ["test.js", `${i}/entry2.${ext}`, `${i}/runtime~entry2.${ext}`];
default:
break;
}
Expand Down
12 changes: 6 additions & 6 deletions test/configCases/asset-modules/only-entry/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ it("should work", () => {
break;
}
case 2: {
expect(stats.assets.length).toBe(4);
expect(stats.assets.length).toBe(3);

const cssEntryInJs = stats.assets.find(
a => a.name.endsWith("css-entry.js")
);
expect(Boolean(cssEntryInJs)).toBe(true);
expect(Boolean(cssEntryInJs)).toBe(false);

const cssEntry = stats.assets.find(
a => a.name.endsWith("css-entry.css")
Expand All @@ -40,7 +40,7 @@ it("should work", () => {
break;
}
case 3: {
expect(stats.assets.length).toBe(5);
expect(stats.assets.length).toBe(4);

const jsEntry = stats.assets.find(
a => a.name.endsWith("js-entry.js")
Expand All @@ -50,7 +50,7 @@ it("should work", () => {
const cssEntryInJs = stats.assets.find(
a => a.name.endsWith("css-entry.js")
);
expect(Boolean(cssEntryInJs)).toBe(true);
expect(Boolean(cssEntryInJs)).toBe(false);

const cssEntry = stats.assets.find(
a => a.name.endsWith("css-entry.css")
Expand All @@ -59,7 +59,7 @@ it("should work", () => {
break;
}
case 4: {
expect(stats.assets.length).toBe(4);
expect(stats.assets.length).toBe(3);

const jsEntry = stats.assets.find(
a => a.name.endsWith("js-entry.js")
Expand All @@ -69,7 +69,7 @@ it("should work", () => {
const cssEntryInJs = stats.assets.find(
a => a.name.endsWith("css-entry.js")
);
expect(Boolean(cssEntryInJs)).toBe(true);
expect(Boolean(cssEntryInJs)).toBe(false);
break;
}
case 5: {
Expand Down
143 changes: 141 additions & 2 deletions test/helpers/FakeDocument.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,67 @@ class FakeDocument {
return this._elementsByTagName.get(name) || [];
}

querySelectorAll(selector) {
// Simple selector support for common cases
// Tag selector: "link", "script", etc.
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(selector)) {
return this.getElementsByTagName(selector);
}
// Class selector: ".class"
if (selector.startsWith(".")) {
const className = selector.slice(1);
const allElements = [];
for (const elements of this._elementsByTagName.values()) {
for (const element of elements) {
if (element.getAttribute("class") === className) {
allElements.push(element);
}
}
}
return allElements;
}
// ID selector: "#id"
if (selector.startsWith("#")) {
const id = selector.slice(1);
for (const elements of this._elementsByTagName.values()) {
for (const element of elements) {
if (element.getAttribute("id") === id) {
return [element];
}
}
}
return [];
}
// Attribute selector: "[attr]", "[attr=value]"
if (selector.startsWith("[") && selector.endsWith("]")) {
const attrSelector = selector.slice(1, -1);
const allElements = [];
if (attrSelector.includes("=")) {
const [attr, value] = attrSelector
.split("=")
.map((s) => s.trim().replace(/^["']|["']$/g, ""));
for (const elements of this._elementsByTagName.values()) {
for (const element of elements) {
if (element.getAttribute(attr) === value) {
allElements.push(element);
}
}
}
} else {
for (const elements of this._elementsByTagName.values()) {
for (const element of elements) {
if (element.getAttribute(attrSelector) !== undefined) {
allElements.push(element);
}
}
}
}
return allElements;
}
// Default: return empty array for unsupported selectors
return [];
}

getComputedStyle(element) {
const style = { getPropertyValue };
const links = this.getElementsByTagName("link");
Expand Down Expand Up @@ -83,8 +144,11 @@ class FakeElement {

_load(node) {
if (node._type === "link") {
setTimeout(() => {
if (node.onload) node.onload({ type: "load", target: node });
const timer = setTimeout(() => {
clearTimeout(timer);
const loadEvent = { type: "load", target: node };
if (node.onload) node.onload(loadEvent);
node._dispatchEvent(loadEvent);
}, 100);
} else if (node._type === "script" && this._document.onScript) {
Promise.resolve().then(() => {
Expand Down Expand Up @@ -184,6 +248,81 @@ class FakeElement {
set rel(value) {
this._attributes.rel = value;
}

addEventListener(event, handler) {
if (!this._eventListeners) {
this._eventListeners = new Map();
}
if (!this._eventListeners.has(event)) {
this._eventListeners.set(event, []);
}
this._eventListeners.get(event).push(handler);
}

removeEventListener(event, handler) {
if (!this._eventListeners) return;
const handlers = this._eventListeners.get(event);
if (!handlers) return;
const index = handlers.indexOf(handler);
if (index >= 0) {
handlers.splice(index, 1);
}
}

_dispatchEvent(event) {
if (!this._eventListeners) return;
const handlers = this._eventListeners.get(event.type);
if (handlers) {
for (const handler of handlers) {
handler(event);
}
}
}

cloneNode(deep = false) {
const cloned = new FakeElement(
this._document,
this._type,
this._document._basePath
);

// Copy attributes
cloned._attributes = { ...this._attributes };

// Copy src and href
cloned._src = this._src;
cloned._href = this._href;

// For link elements, create a new sheet with the same href
if (this._type === "link" && this.sheet) {
cloned.sheet = new FakeSheet(cloned, this._document._basePath);
if (this._href) {
cloned.href = this._href;
}
}

// Copy event handlers if they exist
if (this.onload) {
cloned.onload = this.onload;
}
// Copy event listeners
if (this._eventListeners) {
cloned._eventListeners = new Map();
for (const [event, handlers] of this._eventListeners.entries()) {
cloned._eventListeners.set(event, [...handlers]);
}
}

// Deep clone children if requested
if (deep) {
for (const child of this._children) {
const clonedChild = child.cloneNode(true);
cloned.appendChild(clonedChild);
}
}

return cloned;
}
}

class FakeSheet {
Expand Down
12 changes: 12 additions & 0 deletions test/hotCases/css/css-entry-mini-extract/entry.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
body {
color: red;
}
---
body {
color: blue;
}
---
body {
color: green;
}

Loading
Loading