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/css-parser-hoist-regex-release-comments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack": patch
---

Reduce CSS parser CPU (hoisted per-call regexes, byte-compared `@container` pure-mode keywords) and stop retaining parsed comments on the reused parser instance between modules.
229 changes: 126 additions & 103 deletions lib/css/CssParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const CC_LEFT_CURLY = "{".charCodeAt(0);

// A parsed CSS comment. `loc` is computed on demand — only magic-comment error
// warnings read it, so comment-heavy CSS skips the per-comment line/col work.
// Comments are kept in a flat `this.comments` side array (not AST nodes); `loc` is derived lazily via `rangeLoc` only where needed (magic-comment errors).
// Comments are kept in a flat per-parse `comments` side array (not AST nodes); `loc` is derived lazily via `rangeLoc` only where needed (magic-comment errors).
/** @typedef {{ value: string, range: Range }} Comment */

// Newlines (CSS Syntax 3 §3.3) — listed explicitly since there's no preprocessing stage.
Expand All @@ -97,6 +97,11 @@ const CSS_COMMENT = /\/\*((?!\*\/)[\s\S]*?)\*\//g;
// `@value` recognizers (postcss-modules-values shape): the import form `<names> from <source>`, and the `<importName> as <localName>` alias inside it.
const VALUE_IMPORT_FORM = /from(\/\*|\s)(?:[\s\S]+)$/i;
const VALUE_AS_ALIAS = /\s+as\s+/;
// `@value name value`: end of the name run (first non-space followed by space).
const VALUE_NAME_BOUNDARY = /\S\s/;
const ONLY_WHITESPACE = /^\s+$/;
// Relative request prefix (`./` or `../`) — `isSelfReferenceRequest` per `from`.
const RELATIVE_REQUEST = /^\.{1,2}\//;

/**
* Returns matches.
Expand Down Expand Up @@ -518,6 +523,26 @@ const skipWhiteLine = (input, pos) => {
return pos;
};

/**
* Whether the ident byte-range is a `@container` prelude keyword (`none`/`and`/`or`/`not`, lowercase only) — byte comparison avoids slicing a transient string per prelude ident.
* @param {string} input source
* @param {number} start start offset
* @param {number} end end offset
* @returns {boolean} true for a container keyword
*/
const isContainerKeyword = (input, start, end) => {
switch (end - start) {
case 2:
return input.startsWith("or", start);
case 3:
return input.startsWith("and", start) || input.startsWith("not", start);
case 4:
return input.startsWith("none", start);
default:
return false;
}
};

/**
* @param {string} input source
* @param {number} pos position
Expand Down Expand Up @@ -637,13 +662,16 @@ const parseValueAtRuleParams = (str) => {
value = str.slice(idx + 1);
} else {
const mask = str.replace(CSS_COMMENT, (m) => " ".repeat(m.length));
const idx = mask.search(/\S\s/) + 1;
const idx = mask.search(VALUE_NAME_BOUNDARY) + 1;

localName = str.slice(0, idx).replace(CSS_COMMENT, "").trim();
value = str.slice(idx + (str[idx] === " " ? 1 : 0));
}

if (value.length > 0 && !/^\s+$/.test(value.replace(CSS_COMMENT, ""))) {
if (
value.length > 0 &&
!ONLY_WHITESPACE.test(value.replace(CSS_COMMENT, ""))
) {
value = value.trim();
}

Expand Down Expand Up @@ -794,8 +822,6 @@ class CssParser extends Parser {
grid: true,
...options
};
/** @type {Comment[] | undefined} */
this.comments = undefined;
this.magicCommentContext = createMagicCommentContext();
}

Expand Down Expand Up @@ -856,8 +882,9 @@ class CssParser extends Parser {
source = source.slice(1);
}

// Reset per-parse — parser instances are reused across modules.
this.comments = [];
// Per-parse comment side-array — kept local (like HtmlParser) so nothing is retained on the reused parser instance between modules.
/** @type {Comment[]} */
const comments = [];

const module = state.module;

Expand Down Expand Up @@ -921,7 +948,7 @@ class CssParser extends Parser {
* @returns {boolean} true if request resolves to the current module
*/
const isSelfReferenceRequest = (request) => {
if (!/^\.{1,2}\//.test(request)) return false;
if (!RELATIVE_REQUEST.test(request)) return false;
if (!module.context) return false;
const parsedRequest = parseResource(request);
if (parsedRequest.query !== parsedModuleResource.query) return false;
Expand Down Expand Up @@ -993,7 +1020,7 @@ class CssParser extends Parser {
* @returns {boolean} true when `webpackIgnore: true`
*/
const webpackIgnored = (range, warnStart, warnEnd) => {
const { options, errors } = this.parseCommentOptions(range);
const { options, errors } = parseCommentOptions(range);
if (errors) {
for (const e of errors) {
state.module.addWarning(
Expand Down Expand Up @@ -1217,15 +1244,14 @@ class CssParser extends Parser {
modeData ? modeData === "local" : mode === "local";

/**
* Comment callback: push every comment-token (in source order) onto `this.comments`, read back by `advanceCommentCursor` (pure-mode flags) and `parseCommentOptions` (magic comments).
* Comment callback: push every comment-token (in source order) onto the local `comments`, read back by `advanceCommentCursor` (pure-mode flags) and `parseCommentOptions` (magic comments).
* @param {string} input input
* @param {number} start start
* @param {number} end end
* @returns {number} end
*/
const comment = (input, start, end) => {
if (!this.comments) this.comments = [];
this.comments.push({
comments.push({
value: input.slice(start + 2, end - 2),
range: [start, end]
});
Expand All @@ -1240,16 +1266,98 @@ class CssParser extends Parser {
let cursor = 0;
/** @param {number} until source position to advance the cursor to */
return (until) => {
if (!pure.enabled || !this.comments) return;
while (cursor < this.comments.length) {
const c = this.comments[cursor];
if (!pure.enabled) return;
while (cursor < comments.length) {
const c = comments[cursor];
if (c.range[1] > until) return;
pure.applyComment(c.value);
cursor++;
}
};
})();

const magicCommentContext = this.magicCommentContext;

/**
* Comments fully inside `range`, via binary search over the source-ordered `comments`.
* @param {Range} range range
* @returns {Comment[]} comments in the range
*/
const getComments = (range) => {
// No comments: skip the binary search + result-array allocation (the common case — most CSS has no magic comments).
if (comments.length === 0) return [];
const [start, end] = range;
let idx = binarySearchBounds.ge(comments, start, compareCommentStart);
/** @type {Comment[]} */
const commentsInRange = [];
while (comments[idx] && comments[idx].range[1] <= end) {
commentsInRange.push(comments[idx]);
idx++;
}
return commentsInRange;
};

/**
* Parse webpack magic-comment options from any comment inside `range`.
* @param {Range} range range of the comment
* @returns {{ options: Record<string, EXPECTED_ANY> | null, errors: (Error & { comment: Comment })[] | null }} result
*/
const parseCommentOptions = (range) => {
const found = getComments(range);
if (found.length === 0) {
return EMPTY_COMMENT_OPTIONS;
}
/** @type {Record<string, EXPECTED_ANY>} */
const options = {};
/** @type {(Error & { comment: Comment })[]} */
const errors = [];
for (const c of found) {
const { value } = c;
if (value && webpackCommentRegExp.test(value)) {
// Fast path for the common `webpackXxx: <bool|number|null>` pair, keeping it out of `vm.runInContext`.
const fast = MAGIC_COMMENT_FAST_PATH.exec(value);
if (fast !== null) {
const key = fast[1];
const raw = fast[2];
options[key] =
raw === "true"
? true
: raw === "false"
? false
: raw === "null"
? null
: Number(raw);
continue;
}
// try compile only if webpack options comment is present
try {
for (let [key, val] of Object.entries(
vm.runInContext(
`(function(){return {${value}};})()`,
magicCommentContext
)
)) {
if (typeof val === "object" && val !== null) {
val =
val.constructor.name === "RegExp"
? new RegExp(val)
: JSON.parse(JSON.stringify(val));
}
options[key] = val;
}
} catch (err) {
const newErr = new Error(
String(/** @type {Error} */ (err).message)
);
newErr.stack = String(/** @type {Error} */ (err).stack);
Object.assign(newErr, { comment: c });
errors.push(/** @type {(Error & { comment: Comment })} */ (newErr));
}
}
}
return { options, errors };
};

// CSS modules stuff

/**
Expand Down Expand Up @@ -2937,8 +3045,6 @@ class CssParser extends Parser {
isCounterStyle,
isContainer
) => {
/** @type {RegExp | null} */
const identSkip = isContainer ? /^(none|and|or|not)$/ : null;
const acceptIdent = isKeyframes || isCounterStyle || isContainer;
const acceptString = isKeyframes;
for (const cv of at.prelude) {
Expand All @@ -2949,8 +3055,9 @@ class CssParser extends Parser {
}
if (cv.type === NodeType.Ident) {
if (!acceptIdent) break;
const text = source.slice(cv.start, cv.end);
if (identSkip && identSkip.test(text)) continue;
if (isContainer && isContainerKeyword(source, cv.start, cv.end)) {
continue;
}
pure.markLocal();
break;
}
Expand Down Expand Up @@ -3527,90 +3634,6 @@ class CssParser extends Parser {

return state;
}

/**
* Returns comments in the range.
* @param {Range} range range
* @returns {Comment[]} comments in the range
*/
getComments(range) {
const comments = /** @type {Comment[]} */ (this.comments);
// No comments: skip the binary search + result-array allocation entirely
// (the common case — most CSS has no magic comments).
if (!comments || comments.length === 0) return [];
const [start, end] = range;
let idx = binarySearchBounds.ge(comments, start, compareCommentStart);
/** @type {Comment[]} */
const commentsInRange = [];
while (
comments[idx] &&
/** @type {Range} */ (comments[idx].range)[1] <= end
) {
commentsInRange.push(comments[idx]);
idx++;
}

return commentsInRange;
}

/**
* Parses comment options.
* @param {Range} range range of the comment
* @returns {{ options: Record<string, EXPECTED_ANY> | null, errors: (Error & { comment: Comment })[] | null }} result
*/
parseCommentOptions(range) {
const comments = this.getComments(range);
if (comments.length === 0) {
return EMPTY_COMMENT_OPTIONS;
}
/** @type {Record<string, EXPECTED_ANY>} */
const options = {};
/** @type {(Error & { comment: Comment })[]} */
const errors = [];
for (const comment of comments) {
const { value } = comment;
if (value && webpackCommentRegExp.test(value)) {
// Fast path for the common `webpackXxx: <bool|number|null>` pair, keeping it out of `vm.runInContext`.
const fast = MAGIC_COMMENT_FAST_PATH.exec(value);
if (fast !== null) {
const key = fast[1];
const raw = fast[2];
options[key] =
raw === "true"
? true
: raw === "false"
? false
: raw === "null"
? null
: Number(raw);
continue;
}
// try compile only if webpack options comment is present
try {
for (let [key, val] of Object.entries(
vm.runInContext(
`(function(){return {${value}};})()`,
this.magicCommentContext
)
)) {
if (typeof val === "object" && val !== null) {
val =
val.constructor.name === "RegExp"
? new RegExp(val)
: JSON.parse(JSON.stringify(val));
}
options[key] = val;
}
} catch (err) {
const newErr = new Error(String(/** @type {Error} */ (err).message));
newErr.stack = String(/** @type {Error} */ (err).stack);
Object.assign(newErr, { comment });
errors.push(/** @type {(Error & { comment: Comment })} */ (newErr));
}
}
}
return { options, errors };
}
}

module.exports = CssParser;
9 changes: 9 additions & 0 deletions test/configCases/css/pure-container-keywords/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as style from "./style.module.css";

it("should export @container names as locals in pure mode", () => {
expect(typeof style.box).toBe("string");
expect(typeof style.xy).toBe("string");
expect(typeof style.abc).toBe("string");
expect(typeof style.wrap).toBe("string");
expect(typeof style.sidebar).toBe("string");
});
35 changes: 35 additions & 0 deletions test/configCases/css/pure-container-keywords/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
.box {
color: red;
}

/* `@container` names of length 2/3/4/7 each exercise one arm of the parser's
container-keyword byte check (a name is the first prelude ident). */
@container xy (min-width: 100px) {
.box {
color: blue;
}
}

@container abc (min-width: 100px) {
.box {
color: green;
}
}

@container wrap (min-width: 100px) {
.box {
color: pink;
}
}

@container sidebar (min-width: 100px) {
.box {
color: black;
}
}

/* A non-fast-path magic comment (multiple keys, regex + object values) drives
the `vm.runInContext` parse path; `webpackIgnore: true` keeps the URL as-is. */
.icon {
background: /* webpackIgnore: true, re: /x/, obj: { y: 1 } */ url("https://github.com/webpack/webpack/pull/21202/"./ignored.png"");
}
Loading
Loading