Skip to content

perf: speed up non-CSS-Modules CSS parsing by skipping unused AST work#21324

Merged
alexander-akait merged 12 commits into
mainfrom
claude/postcss-css-parser-perf-w7f7mt
Jul 2, 2026
Merged

perf: speed up non-CSS-Modules CSS parsing by skipping unused AST work#21324
alexander-akait merged 12 commits into
mainfrom
claude/postcss-css-parser-perf-w7f7mt

Conversation

@alexander-akait

Copy link
Copy Markdown
Member

Summary

The CSS parser builds a full CSS-Syntax component-value tree even for parts nothing downstream reads. This adds a CssProcessOptions.skip that lets a parse leave unread parts un-materialized, on two independent axes:

  • skip.types — a NodeType-indexed set of component-value node types to drop from declaration value / function-arg lists (built with the exported buildSkipSet);
  • skip.selectorPrelude / skip.atRulePrelude — drop a qualified-rule / at-rule prelude (url tokens and functions are kept, so url() in a selector and @import url(…) still resolve).

CssParser enables this only for non-CSS-Modules parses, where the Ident visitor no-ops, the Declaration visitor returns early, and there is no ICSS — so it drops selector preludes plus numeric/ident/hash/colon/delim value leaves. That trims non-modules parse time ~10–15% on large stylesheets (e.g. Tailwind) with byte-identical output. CSS-Modules parses skip nothing (selectors are walked and ICSS :export captures arbitrary value ranges).

Supporting refactors: comments now reach the visitor map through a new NodeType.Comment instead of an ad-hoc process({ comment }) callback (fired during tokenization on a transient node), and SourceProcessor can carry default process options so skip is configured on the instance. Node materialization is unchanged for the parseA* entry points and the object backend. Refs the CSS parser performance work.

What kind of change does this PR introduce?

perf (with supporting refactors: the unified skip API, the NodeType.Comment visitor, and SourceProcessor instance options).

Did you add tests for your changes?

Yes. test/walkCssTokensParser.unittest.js gains a skip set suite covering every skippable node type (each leaf type in both value and function-arg lists, Function/SimpleBlock subtree drops, selectorPrelude/atRulePrelude incl. url-preservation, combined sets, the object-backend reset, and instance-level skip options). The existing ConfigTestCases CSS cases and the CSS parsing/spec unit suites pass unchanged, and output was verified byte-identical (real webpack() build diff) in both modes.

Does this PR introduce a breaking change?

No. Output is byte-identical in both modes; the change is internal to the CSS parser.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

n/a — CssProcessOptions.skip, buildSkipSet, and NodeType are internal to lib/css and not part of the public webpack API surface.

Use of AI

Yes. This was developed with Claude (Anthropic) via Claude Code: profiling the parser hot paths, designing/implementing the skip mechanism, writing the tests, and benchmarking against postcss / css-tree / cssom / Lightning CSS. All changes were human-reviewed; correctness is backed by the existing CSS test suite (config cases + parser unit/spec tests) and byte-identical build diffs. Per the webpack AI policy.

🤖 Generated with Claude Code


Generated by Claude Code

Non-modules CSS parses never walk selectors, so consumeAQualifiedRule now
scans the prelude without materializing its component-value nodes (url
tokens and functions are kept so url() rewriting still fires). Trims ~4%
off non-modules parsing; CSS-Modules parses are unchanged and output is
byte-identical.
Comments now reach the visitor map via NodeType.Comment instead of a
separate process({ comment }) callback, giving one uniform interface for
every token/node. They fire during tokenization (source order among
comments) on a transient SoA node, so A.start/end/loc/source work like any
other node. No comment visitor registered means no callback and zero added
work. Behavior is unchanged; magic comments and CSS-Modules pure-mode still
read the same comments side-array.
Adds a per-parse skipLeafTypes set (buildSkipLeafSet helper) so component-value
list builders can drop leaf node types nothing reads. Non-CSS-Modules parses now
skip numeric / ident / hash / colon / delim value tokens (the Ident visitor
no-ops and the Declaration visitor returns early there), which with the existing
selector-prelude skip cuts non-modules parsing time by ~13%. CSS-Modules parses
skip nothing — ICSS :export captures arbitrary value byte ranges. Output is
byte-identical in both modes.
Collapses skipSelectorPrelude (boolean) and skipLeafTypes (Uint8Array) into a
single CssProcessOptions.skip set indexed by NodeType: leaf-type entries drop
value / function-arg leaves, the QualifiedRule entry drops selector preludes.
buildSkipLeafSet becomes buildSkipSet. Behavior and output are unchanged.
Adds unit tests proving CssProcessOptions.skip drops each leaf type from both
value and function-arg lists, that skipping a Function drops its whole subtree,
that a combined set drops all listed types, that QualifiedRule drops selector
preludes while keeping the block and url() rewrites, and that the object backend
(parseA*) ignores a prior skip.
Records the two structural (not just consumer) constraints for reuse: skipping
both Delim and Ident loses !important detection, and skipping SimpleBlock loses
the custom-property {}-value check.
Completes the reusable skip mechanism: skip[AtRule] drops an at-rule prelude
(symmetric with skip[QualifiedRule], keeping url tokens / functions so
@import url(...) still resolves), and the stray-} value-list path now honours
the skip set too, so every NodeType that can appear in a component-value list
is skippable. Tests cover each leaf type, Function and SimpleBlock subtrees,
both prelude toggles, and the object-backend reset. webpack still enables only
its lean non-modules set; the added capability is for reuse.
The flat NodeType-indexed skip set was misleading: skip[Number] dropped number
nodes but skip[QualifiedRule]/skip[AtRule] dropped a rule's *prelude*, not the
rule. Replace it with a structured CssProcessOptions.skip:
  { types?: Uint8Array, selectorPrelude?: boolean, atRulePrelude?: boolean }
so value-type drops and prelude drops read unambiguously. buildSkipSet now
builds only the types set. Behavior and output unchanged.
The SourceProcessor constructor now takes default process options (skip, as, …)
merged under each process() call's own options, so skip is configured on the
processor instance and reused across parses rather than repeated per call.
CssParser now sets as/skip on the processor and passes only the per-source
locConverter to process(). Backward-compatible: HtmlParser and per-call options
are unchanged.
Copilot AI review requested due to automatic review settings July 1, 2026 16:57
@changeset-bot

changeset-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0b31d42

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
webpack Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

This PR is packaged and the instant preview is available (00d8b2f).

Install it locally:

  • npm
npm i -D webpack@https://pkg.pr.new/webpack@00d8b2f
  • yarn
yarn add -D webpack@https://pkg.pr.new/webpack@00d8b2f
  • pnpm
pnpm add -D webpack@https://pkg.pr.new/webpack@00d8b2f

@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.43%. Comparing base (3cb8578) to head (0b31d42).
⚠️ Report is 5 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #21324      +/-   ##
==========================================
- Coverage   92.86%   92.43%   -0.44%     
==========================================
  Files         594      594              
  Lines       65210    65269      +59     
  Branches    18143    18169      +26     
==========================================
- Hits        60559    60333     -226     
- Misses       4651     4936     +285     
Flag Coverage Δ
css-parsing 28.64% <66.26%> (+0.03%) ⬆️
html5lib 28.20% <16.86%> (-2.93%) ⬇️
integration 88.86% <79.51%> (-0.03%) ⬇️
test262 45.55% <12.04%> (-0.01%) ⬇️
unit 43.12% <92.77%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes webpack’s internal CSS parser by allowing streaming parses to skip building/retaining AST parts that are not consumed for non-CSS-Modules styles, while keeping CSS-Modules behavior unchanged.

Changes:

  • Added CssProcessOptions.skip (including buildSkipSet) to selectively skip component-value nodes and optionally skip selector/at-rule preludes.
  • Routed comment handling through a NodeType.Comment visitor instead of a dedicated comment process callback.
  • Extended util/SourceProcessor to accept default per-instance process options that are merged with per-call options.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
test/walkCssTokensParser.unittest.js Updates comment expectations to NodeType.Comment and adds comprehensive unit coverage for skip behavior.
lib/util/SourceProcessor.js Adds instance-level default options and merges them into per-call process() options.
lib/css/syntax.js Implements skip state in the CSS streaming grammar, adds NodeType.Comment, and exports buildSkipSet.
lib/css/CssParser.js Enables skipping only for non-CSS-Modules parses and switches comment collection to the comment visitor.
.changeset/css-skip-value-leaves.md Changeset entry for value-token skipping perf improvement.
.changeset/css-skip-selector-prelude.md Changeset entry for selector-prelude skipping perf improvement.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

CI's tsc -p tsconfig.types.test.json inferred the tuple array as
(string | number)[][], so indexing the counts record and calling
buildSkipSet([type]) failed (TS7015 / TS2322). Annotate the array.
@codspeed-hq

codspeed-hq Bot commented Jul 1, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 85.5%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 4 improved benchmarks
❌ 2 regressed benchmarks
✅ 138 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory benchmark "context-esm", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 566 KB 947.1 KB -40.24%
Memory benchmark "asset-modules-source", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 191.6 KB 246.1 KB -22.15%
Memory benchmark "side-effects-reexport", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 851.7 KB 129.2 KB ×6.6
Memory benchmark "asset-modules-bytes", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 858.9 KB 247.8 KB ×3.5
Memory benchmark "asset-modules-inline", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 806.4 KB 381.6 KB ×2.1
Memory benchmark "wasm-modules-async", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 343 KB 189.2 KB +81.29%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/postcss-css-parser-perf-w7f7mt (0b31d42) with main (5461ae4)

Open in CodSpeed

Copilot AI review requested due to automatic review settings July 1, 2026 17:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment thread lib/css/syntax.js
Comment thread lib/css/syntax.js
Comment thread lib/css/syntax.js Outdated
Test the skip set for an explicit 1 rather than !== 0 so an out-of-range
lookup on a short skip.types array keeps the node instead of dropping it.
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Types Coverage

Coverage after merging claude/postcss-css-parser-perf-w7f7mt into main will be
99.36%
Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
bin
   webpack.js98.77%100%100%98.77%91
examples
   build-common.js100%100%100%100%
   buildAll.js100%100%100%100%
   examples.js100%100%100%100%
   template-common.js98.21%100%100%98.21%72
examples/custom-javascript-parser
   test.filter.js100%100%100%100%
examples/custom-javascript-parser/internals
   acorn-parse.js100%100%100%100%
   meriyah-parse.js100%100%100%100%
   oxc-parse.js91.30%100%100%91.30%140, 142–143, 145, 147, 153–154, 161, 168, 90
examples/markdown
   webpack.config.mjs100%100%100%100%
examples/module-federation
   test.filter.js100%100%100%100%
examples/reexport-components
   test.filter.js100%100%100%100%
examples/typescript
   test.filter.js100%100%100%100%
examples/typescript-non-erasable
   test.filter.js50%100%100%50%5
examples/virtual-modules
   test.filter.js100%100%100%100%
examples/wasm-bindgen-esm
   test.filter.js100%100%100%100%
examples/wasm-complex
   test.filter.js100%100%100%100%
examples/wasm-emscripten
   test.filter.js100%100%100%100%
examples/wasm-simple
   test.filter.js100%100%100%100%
examples/wasm-simple-source-phase
   test.filter.js100%100%100%100%
lib
   APIPlugin.js100%100%100%100%
   AsyncDependenciesBlock.js100%100%100%100%
   AutomaticPrefetchPlugin.js100%100%100%100%
   BannerPlugin.js100%100%100%100%
   Cache.js98.21%100%100%98.21%101
   CacheFacade.js100%100%100%100%
   Chunk.js99.72%100%100%99.72%39
   ChunkGraph.js100%100%100%100%
   ChunkGroup.js100%100%100%100%
   ChunkTemplate.js100%100%100%100%
   CircularModulesPlugin.js98.81%100%100%98.81%136
   CleanPlugin.js99.12%100%100%99.12%207, 227
   CodeGenerationResults.js100%100%100%100%
   CompatibilityPlugin.js100%100%100%100%
   Compilation.js98.43%100%100%98.43%1641, 1960, 1967, 1975, 1997, 2000, 2939, 3418–3419, 3451, 4117, 4147, 4200–4201, 4205, 4210, 4226–4227, 4241–4242, 4247–4248, 4725, 4751, 526, 531, 5559, 5591, 5608, 5624, 5640, 5655, 5680–5681, 5683, 6011, 6016, 6022, 6025, 6037, 6039, 6043, 6059, 6074, 6106, 6160, 6184, 6298, 777–778
   Compiler.js99.56%100%100%99.56%1147–1148, 1156
   ConcatenationScope.js98.65%100%100%98.65%195
   ConditionalInitFragment.js100%100%100%100%
   ConstPlugin.js100%100%100%100%
   ContextExclusionPlugin.js100%100%100%100%
   ContextModule.js100%100%100%100%
   ContextModuleFactory.js97.40%100%100%97.40%258, 395, 418, 420, 424, 433–434
   ContextReplacementPlugin.js100%100%100%100%
   DefinePlugin.js98.99%100%100%98.99%172–173, 189, 208, 282
   DependenciesBlock.js100%100%100%100%
   Dependency.js98.51%100%100%98.51%479, 525
   DependencyTemplate.js100%100%100%100%
   DependencyTemplates.js100%100%100%100%
   DotenvPlugin.js98.41%100%100%98.41%378, 391–392
   DynamicEntryPlugin.js100%100%100%100%
   EntryOptionPlugin.js100%100%100%100%
   EntryPlugin.js100%100%100%100%
   Entrypoint.js100%100%100%100%
   EnvironmentPlugin.js97.14%100%100%97.14%49
   ErrorHelpers.js100%100%100%100%
   EvalDevToolModulePlugin.js100%100%100%100%
   EvalSourceMapDevToolPlugin.js100%100%100%100%
   ExportsInfo.js100%100%100%100%
   ExportsInfoApiPlugin.js100%100%100%100%
   ExternalModule.js98.55%100%100%98.55%1100, 1103, 502–506, 508, 654
   ExternalModuleFactoryPlugin.js100%100%100%100%
   ExternalsPlugin.js100%100%100%100%
   FileSystemInfo.js99.52%100%100%99.52%182, 2382–2383, 2386, 2397, 2408, 2419, 280, 3823, 3838, 3862
   FlagAllModulesAsUsedPlugin.js100%100%100%100%
   FlagDependencyExportsPlugin.js98.42%100%100%98.42%413, 422, 424, 428
   FlagDependencyUsagePlugin.js100%100%100%100%
   FlagEntryExportAsUsedPlugin.js100%100%100%100%
   Generator.js100%100%100%100%
   HotModuleReplacementPlugin.js100%100%100%100%
   HotUpdateChunk.js100%100%100%100%
   IgnorePlugin.js100%100%100%100%
   IgnoreWarningsPlugin.js100%100%100%100%
   InitFragment.js100%100%100%100%
   JavascriptMetaInfoPlugin.js100%100%100%100%
   LazyBarrel.js100%100%100%100%
   LibraryTemplatePlugin.js100%100%100%100%
   LoaderOptionsPlugin.js100%100%100%100%
   LoaderTargetPlugin.js100%100%100%100%
   MainTemplate.js100%100%100%100%
   ManifestPlugin.js100%100%100%100%
   Module.js98.50%100%100%98.50%1285, 1290, 1350, 1364, 1426, 1435
   ModuleFactory.js100%100%100%100%
   ModuleFilenameHelpers.js98.85%100%100%98.85%106, 108
   ModuleGraph.js99.73%100%100%99.73%1005
   ModuleGraphConnection.js100%100%100%100%
   ModuleInfoHeaderPlugin.js100%100%100%100%
   ModuleNotFoundError.js100%100%100%100%
   ModuleProfile.js100%100%100%100%
   ModuleSourceTypeConstants.js100%100%100%100%
   ModuleTemplate.js100%100%100%100%
   ModuleTypeConstants.js100%100%100%100%
   MultiCompiler.js99.69%100%100%99.69%661
   MultiStats.js100%100%100%100%
   MultiWatching.js100%100%100%100%
   NoEmitOnErrorsPlugin.js100%100%100%100%
   NodeStuffPlugin.js100%100%100%100%
   NormalModule.js97.89%100%100%97.89%1223, 1226, 1243, 1260, 1507, 1541, 1557, 1644, 2000, 2299, 2304–2314, 415, 419, 573
   NormalModuleFactory.js99.47%100%100%99.47%1083, 1392, 486, 498
   NormalModuleReplacementPlugin.js100%100%100%100%
   NullFactory.js100%100%100%100%
   OptimizationStages.js100%100%100%100%
   OptionsApply.js100%100%100%100%
   Parser.js100%100%100%100%
   PlatformPlugin.js100%100%100%100%
   PrefetchPlugin.js100%100%100%100%
   ProgressPlugin.js98.85%100%100%98.85%527–528, 533, 535, 599
   ProvidePlugin.js100%100%100%100%
   RawModule.js100%100%100%100%
   RecordIdsPlugin.js100%100%100%100%
   RequestShortener.js100%100%100%100%
   ResolverFactory.js100%100%100%100%
   RuntimeGlobals.js100%100%100%100%
   RuntimeModule.js100%100%100%100%
   RuntimePlugin.js100%100%100%100%
   RuntimeTemplate.js100%100%100%100%
   SelfModuleFactory.js100%100%100%100%
   SingleEntryPlugin.js100%100%100%100%
   SourceMapDevToolModuleOptionsPlugin.js100%100%100%100%
   SourceMapDevToolPlugin.js98.62%100%100%98.62%220, 224, 226, 419, 430, 889
   Stats.js100%100%100%100%
   Template.js100%100%100%100%
   TemplatedPathPlugin.js99.43%100%100%99.43%308–309
   UseStrictPlugin.js100%100%100%100%
   WarnCaseSensitiveModulesPlugin.js100%100%100%100%
   WarnDeprecatedOptionPlugin.js100%100%100%100%
   WarnNoModeSetPlugin.js100%100%100%100%
   WatchIgnorePlugin.js100%100%100%100%
   Watching.js100%100%100%100%
   WebpackError.js100%100%100%100%
   WebpackIsIncludedPlugin.js100%100%100%100%
   WebpackOptionsApply.js100%100%100%100%
   WebpackOptionsDefaulter.js100%100%100%100%
   buildChunkGraph.js99.87%100%100%99.87%371
   cli.js98.63%100%100%98.63%10, 119, 549, 581, 631, 905
   index.js99.72%100%100%99.72%184
   validateSchema.js94.67%100%100%94.67%100, 87, 89, 98
   webpack.js96.33%100%100%96.33%10, 198, 220, 222
lib/asset
   AssetBytesGenerator.js100%100%100%100%
   AssetBytesParser.js100%100%100%100%
   AssetGenerator.js100%100%100%100%
   AssetModule.js100%100%100%100%
   AssetModulesPlugin.js97.33%100%100%97.33%282, 306, 309, 36, 362, 41
   AssetParser.js100%100%100%100%
   AssetSourceGenerator.js100%100%100%100%
   AssetSourceParser.js100%100%100%100%
   RawDataUrlModule.js100%100%100%100%
lib/async-modules
   AsyncModuleHelpers.js100%100%100%100%
   AwaitDependenciesInitFragment.js100%100%100%100%
   InferAsyncModulesPlugin.js100%100%100%100%
lib/bun
   BunTargetPlugin.js100%100%100%100%
lib/cache
   AddBuildDependenciesPlugin.js100%100%100%100%
   AddManagedPathsPlugin.js100%100%100%100%
   IdleFileCachePlugin.js97.92%100%100%97.92%75, 87, 95
   MemoryCachePlugin.js95.83%100%100%95.83%33
   MemoryWithGcCachePlugin.js93.15%100%100%93.15%107, 114–115, 123, 90
   PackFileCacheStrategy.js96.40%100%100%96.40%1251, 1351, 1355, 1417, 628, 647, 657–659, 661, 677–678, 683, 686, 688, 693, 698, 723, 729, 763, 769, 775, 780, 791, 800, 805–806, 808, 825, 831–832, 834
   ResolverCachePlugin.js100%100%100%100%
   getLazyHashedEtag.js100%100%100%100%
   mergeEtags.js100%100%100%100%
lib/config
   browserslistTargetHandler.js100%100%100%100%
   defaults.js99.33%100%100%99.33%1468–1470, 1478, 274,

@alexander-akait alexander-akait merged commit 00d8b2f into main Jul 2, 2026
62 of 63 checks passed
@alexander-akait alexander-akait deleted the claude/postcss-css-parser-perf-w7f7mt branch July 2, 2026 14:37
@raquelpn1979-tech

Copy link
Copy Markdown

vercel/next.js#68160

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants