Skip to content

Commit bafc508

Browse files
perf: reduce CSS parser CPU and retained comment state (#21202)
* perf: hoist CSS parser regexes and release per-module comments * perf: avoid transient string in CSS @container pure-mode keyword check * refactor: scope CSS parser comments locally, mirroring HtmlParser Comments are now kept in a per-parse local array instead of the reused parser instance, and the internal-only getComments/parseCommentOptions methods become local closures — matching HtmlParser, which holds no per-parse comment state. No behavior or measurable perf/memory change. * chore: broaden CSS parser changeset to cover the @container keyword check * test: cover CSS @container keyword check and magic-comment vm path Adds a pure css/module config case whose named @container rules exercise each arm of the container-keyword byte check, plus a non-fast-path url magic comment that drives the parseCommentOptions vm.runInContext path.
1 parent 7552543 commit bafc508

6 files changed

Lines changed: 196 additions & 121 deletions

File tree

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+
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.

lib/css/CssParser.js

Lines changed: 126 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const CC_LEFT_CURLY = "{".charCodeAt(0);
7575

7676
// A parsed CSS comment. `loc` is computed on demand — only magic-comment error
7777
// warnings read it, so comment-heavy CSS skips the per-comment line/col work.
78-
// 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).
78+
// 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).
7979
/** @typedef {{ value: string, range: Range }} Comment */
8080

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

101106
/**
102107
* Returns matches.
@@ -518,6 +523,26 @@ const skipWhiteLine = (input, pos) => {
518523
return pos;
519524
};
520525

526+
/**
527+
* 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.
528+
* @param {string} input source
529+
* @param {number} start start offset
530+
* @param {number} end end offset
531+
* @returns {boolean} true for a container keyword
532+
*/
533+
const isContainerKeyword = (input, start, end) => {
534+
switch (end - start) {
535+
case 2:
536+
return input.startsWith("or", start);
537+
case 3:
538+
return input.startsWith("and", start) || input.startsWith("not", start);
539+
case 4:
540+
return input.startsWith("none", start);
541+
default:
542+
return false;
543+
}
544+
};
545+
521546
/**
522547
* @param {string} input source
523548
* @param {number} pos position
@@ -637,13 +662,16 @@ const parseValueAtRuleParams = (str) => {
637662
value = str.slice(idx + 1);
638663
} else {
639664
const mask = str.replace(CSS_COMMENT, (m) => " ".repeat(m.length));
640-
const idx = mask.search(/\S\s/) + 1;
665+
const idx = mask.search(VALUE_NAME_BOUNDARY) + 1;
641666

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

646-
if (value.length > 0 && !/^\s+$/.test(value.replace(CSS_COMMENT, ""))) {
671+
if (
672+
value.length > 0 &&
673+
!ONLY_WHITESPACE.test(value.replace(CSS_COMMENT, ""))
674+
) {
647675
value = value.trim();
648676
}
649677

@@ -794,8 +822,6 @@ class CssParser extends Parser {
794822
grid: true,
795823
...options
796824
};
797-
/** @type {Comment[] | undefined} */
798-
this.comments = undefined;
799825
this.magicCommentContext = createMagicCommentContext();
800826
}
801827

@@ -856,8 +882,9 @@ class CssParser extends Parser {
856882
source = source.slice(1);
857883
}
858884

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

862889
const module = state.module;
863890

@@ -921,7 +948,7 @@ class CssParser extends Parser {
921948
* @returns {boolean} true if request resolves to the current module
922949
*/
923950
const isSelfReferenceRequest = (request) => {
924-
if (!/^\.{1,2}\//.test(request)) return false;
951+
if (!RELATIVE_REQUEST.test(request)) return false;
925952
if (!module.context) return false;
926953
const parsedRequest = parseResource(request);
927954
if (parsedRequest.query !== parsedModuleResource.query) return false;
@@ -993,7 +1020,7 @@ class CssParser extends Parser {
9931020
* @returns {boolean} true when `webpackIgnore: true`
9941021
*/
9951022
const webpackIgnored = (range, warnStart, warnEnd) => {
996-
const { options, errors } = this.parseCommentOptions(range);
1023+
const { options, errors } = parseCommentOptions(range);
9971024
if (errors) {
9981025
for (const e of errors) {
9991026
state.module.addWarning(
@@ -1217,15 +1244,14 @@ class CssParser extends Parser {
12171244
modeData ? modeData === "local" : mode === "local";
12181245

12191246
/**
1220-
* Comment callback: push every comment-token (in source order) onto `this.comments`, read back by `advanceCommentCursor` (pure-mode flags) and `parseCommentOptions` (magic comments).
1247+
* Comment callback: push every comment-token (in source order) onto the local `comments`, read back by `advanceCommentCursor` (pure-mode flags) and `parseCommentOptions` (magic comments).
12211248
* @param {string} input input
12221249
* @param {number} start start
12231250
* @param {number} end end
12241251
* @returns {number} end
12251252
*/
12261253
const comment = (input, start, end) => {
1227-
if (!this.comments) this.comments = [];
1228-
this.comments.push({
1254+
comments.push({
12291255
value: input.slice(start + 2, end - 2),
12301256
range: [start, end]
12311257
});
@@ -1240,16 +1266,98 @@ class CssParser extends Parser {
12401266
let cursor = 0;
12411267
/** @param {number} until source position to advance the cursor to */
12421268
return (until) => {
1243-
if (!pure.enabled || !this.comments) return;
1244-
while (cursor < this.comments.length) {
1245-
const c = this.comments[cursor];
1269+
if (!pure.enabled) return;
1270+
while (cursor < comments.length) {
1271+
const c = comments[cursor];
12461272
if (c.range[1] > until) return;
12471273
pure.applyComment(c.value);
12481274
cursor++;
12491275
}
12501276
};
12511277
})();
12521278

1279+
const magicCommentContext = this.magicCommentContext;
1280+
1281+
/**
1282+
* Comments fully inside `range`, via binary search over the source-ordered `comments`.
1283+
* @param {Range} range range
1284+
* @returns {Comment[]} comments in the range
1285+
*/
1286+
const getComments = (range) => {
1287+
// No comments: skip the binary search + result-array allocation (the common case — most CSS has no magic comments).
1288+
if (comments.length === 0) return [];
1289+
const [start, end] = range;
1290+
let idx = binarySearchBounds.ge(comments, start, compareCommentStart);
1291+
/** @type {Comment[]} */
1292+
const commentsInRange = [];
1293+
while (comments[idx] && comments[idx].range[1] <= end) {
1294+
commentsInRange.push(comments[idx]);
1295+
idx++;
1296+
}
1297+
return commentsInRange;
1298+
};
1299+
1300+
/**
1301+
* Parse webpack magic-comment options from any comment inside `range`.
1302+
* @param {Range} range range of the comment
1303+
* @returns {{ options: Record<string, EXPECTED_ANY> | null, errors: (Error & { comment: Comment })[] | null }} result
1304+
*/
1305+
const parseCommentOptions = (range) => {
1306+
const found = getComments(range);
1307+
if (found.length === 0) {
1308+
return EMPTY_COMMENT_OPTIONS;
1309+
}
1310+
/** @type {Record<string, EXPECTED_ANY>} */
1311+
const options = {};
1312+
/** @type {(Error & { comment: Comment })[]} */
1313+
const errors = [];
1314+
for (const c of found) {
1315+
const { value } = c;
1316+
if (value && webpackCommentRegExp.test(value)) {
1317+
// Fast path for the common `webpackXxx: <bool|number|null>` pair, keeping it out of `vm.runInContext`.
1318+
const fast = MAGIC_COMMENT_FAST_PATH.exec(value);
1319+
if (fast !== null) {
1320+
const key = fast[1];
1321+
const raw = fast[2];
1322+
options[key] =
1323+
raw === "true"
1324+
? true
1325+
: raw === "false"
1326+
? false
1327+
: raw === "null"
1328+
? null
1329+
: Number(raw);
1330+
continue;
1331+
}
1332+
// try compile only if webpack options comment is present
1333+
try {
1334+
for (let [key, val] of Object.entries(
1335+
vm.runInContext(
1336+
`(function(){return {${value}};})()`,
1337+
magicCommentContext
1338+
)
1339+
)) {
1340+
if (typeof val === "object" && val !== null) {
1341+
val =
1342+
val.constructor.name === "RegExp"
1343+
? new RegExp(val)
1344+
: JSON.parse(JSON.stringify(val));
1345+
}
1346+
options[key] = val;
1347+
}
1348+
} catch (err) {
1349+
const newErr = new Error(
1350+
String(/** @type {Error} */ (err).message)
1351+
);
1352+
newErr.stack = String(/** @type {Error} */ (err).stack);
1353+
Object.assign(newErr, { comment: c });
1354+
errors.push(/** @type {(Error & { comment: Comment })} */ (newErr));
1355+
}
1356+
}
1357+
}
1358+
return { options, errors };
1359+
};
1360+
12531361
// CSS modules stuff
12541362

12551363
/**
@@ -2937,8 +3045,6 @@ class CssParser extends Parser {
29373045
isCounterStyle,
29383046
isContainer
29393047
) => {
2940-
/** @type {RegExp | null} */
2941-
const identSkip = isContainer ? /^(none|and|or|not)$/ : null;
29423048
const acceptIdent = isKeyframes || isCounterStyle || isContainer;
29433049
const acceptString = isKeyframes;
29443050
for (const cv of at.prelude) {
@@ -2949,8 +3055,9 @@ class CssParser extends Parser {
29493055
}
29503056
if (cv.type === NodeType.Ident) {
29513057
if (!acceptIdent) break;
2952-
const text = source.slice(cv.start, cv.end);
2953-
if (identSkip && identSkip.test(text)) continue;
3058+
if (isContainer && isContainerKeyword(source, cv.start, cv.end)) {
3059+
continue;
3060+
}
29543061
pure.markLocal();
29553062
break;
29563063
}
@@ -3527,90 +3634,6 @@ class CssParser extends Parser {
35273634

35283635
return state;
35293636
}
3530-
3531-
/**
3532-
* Returns comments in the range.
3533-
* @param {Range} range range
3534-
* @returns {Comment[]} comments in the range
3535-
*/
3536-
getComments(range) {
3537-
const comments = /** @type {Comment[]} */ (this.comments);
3538-
// No comments: skip the binary search + result-array allocation entirely
3539-
// (the common case — most CSS has no magic comments).
3540-
if (!comments || comments.length === 0) return [];
3541-
const [start, end] = range;
3542-
let idx = binarySearchBounds.ge(comments, start, compareCommentStart);
3543-
/** @type {Comment[]} */
3544-
const commentsInRange = [];
3545-
while (
3546-
comments[idx] &&
3547-
/** @type {Range} */ (comments[idx].range)[1] <= end
3548-
) {
3549-
commentsInRange.push(comments[idx]);
3550-
idx++;
3551-
}
3552-
3553-
return commentsInRange;
3554-
}
3555-
3556-
/**
3557-
* Parses comment options.
3558-
* @param {Range} range range of the comment
3559-
* @returns {{ options: Record<string, EXPECTED_ANY> | null, errors: (Error & { comment: Comment })[] | null }} result
3560-
*/
3561-
parseCommentOptions(range) {
3562-
const comments = this.getComments(range);
3563-
if (comments.length === 0) {
3564-
return EMPTY_COMMENT_OPTIONS;
3565-
}
3566-
/** @type {Record<string, EXPECTED_ANY>} */
3567-
const options = {};
3568-
/** @type {(Error & { comment: Comment })[]} */
3569-
const errors = [];
3570-
for (const comment of comments) {
3571-
const { value } = comment;
3572-
if (value && webpackCommentRegExp.test(value)) {
3573-
// Fast path for the common `webpackXxx: <bool|number|null>` pair, keeping it out of `vm.runInContext`.
3574-
const fast = MAGIC_COMMENT_FAST_PATH.exec(value);
3575-
if (fast !== null) {
3576-
const key = fast[1];
3577-
const raw = fast[2];
3578-
options[key] =
3579-
raw === "true"
3580-
? true
3581-
: raw === "false"
3582-
? false
3583-
: raw === "null"
3584-
? null
3585-
: Number(raw);
3586-
continue;
3587-
}
3588-
// try compile only if webpack options comment is present
3589-
try {
3590-
for (let [key, val] of Object.entries(
3591-
vm.runInContext(
3592-
`(function(){return {${value}};})()`,
3593-
this.magicCommentContext
3594-
)
3595-
)) {
3596-
if (typeof val === "object" && val !== null) {
3597-
val =
3598-
val.constructor.name === "RegExp"
3599-
? new RegExp(val)
3600-
: JSON.parse(JSON.stringify(val));
3601-
}
3602-
options[key] = val;
3603-
}
3604-
} catch (err) {
3605-
const newErr = new Error(String(/** @type {Error} */ (err).message));
3606-
newErr.stack = String(/** @type {Error} */ (err).stack);
3607-
Object.assign(newErr, { comment });
3608-
errors.push(/** @type {(Error & { comment: Comment })} */ (newErr));
3609-
}
3610-
}
3611-
}
3612-
return { options, errors };
3613-
}
36143637
}
36153638

36163639
module.exports = CssParser;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as style from "./style.module.css";
2+
3+
it("should export @container names as locals in pure mode", () => {
4+
expect(typeof style.box).toBe("string");
5+
expect(typeof style.xy).toBe("string");
6+
expect(typeof style.abc).toBe("string");
7+
expect(typeof style.wrap).toBe("string");
8+
expect(typeof style.sidebar).toBe("string");
9+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
.box {
2+
color: red;
3+
}
4+
5+
/* `@container` names of length 2/3/4/7 each exercise one arm of the parser's
6+
container-keyword byte check (a name is the first prelude ident). */
7+
@container xy (min-width: 100px) {
8+
.box {
9+
color: blue;
10+
}
11+
}
12+
13+
@container abc (min-width: 100px) {
14+
.box {
15+
color: green;
16+
}
17+
}
18+
19+
@container wrap (min-width: 100px) {
20+
.box {
21+
color: pink;
22+
}
23+
}
24+
25+
@container sidebar (min-width: 100px) {
26+
.box {
27+
color: black;
28+
}
29+
}
30+
31+
/* A non-fast-path magic comment (multiple keys, regex + object values) drives
32+
the `vm.runInContext` parse path; `webpackIgnore: true` keeps the URL as-is. */
33+
.icon {
34+
background: /* webpackIgnore: true, re: /x/, obj: { y: 1 } */ url("./ignored.png");
35+
}

0 commit comments

Comments
 (0)