Skip to content

perf(css): reduce CSS build time and memory usage#21114

Merged
alexander-akait merged 16 commits into
mainfrom
claude/css-modules-perf-optimization-I8d2z
Jun 8, 2026
Merged

perf(css): reduce CSS build time and memory usage#21114
alexander-akait merged 16 commits into
mainfrom
claude/css-modules-perf-optimization-I8d2z

Conversation

@alexander-akait

Copy link
Copy Markdown
Member

Summary

CSS-modules builds create roughly one Dependency instance per :export entry (tens of thousands on class-heavy projects), which inflates time and retained memory. This PR consolidates them into a single CssExportsDependency per module (a flat array of plain-object entries) and trims hot-path allocations/lookups in CSS code generation, ModuleGraph.cached, and TemplatedPathPlugin.interpolate.

Measured on synthetic CSS-modules builds: ~10% faster, with peak RSS down ~6% on a small build and ~18% (≈ −180 MB) on a 240k-class build; object count drops from ~480k export dependencies to ~3k. Output is byte-identical. JS/ESM builds are unaffected aside from the small cross-cutting cached/interpolate wins.

What kind of change does this PR introduce?

perf (with a supporting refactor of the CSS export-dependency model).

Did you add tests for your changes?

Behavior is unchanged, so correctness is covered by the existing CSS suites (ConfigTestCases, StatsTestCases, HotTestCases, snapshots) which pass without updates; additionally added seeded fuzz tests for the CSS identifier/convention string utilities in test/cssIdentifier.unittest.js.

Does this PR introduce a breaking change?

No.

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

n/a

Use of AI

Yes. Claude (Anthropic) was used to profile the CSS build, identify hot paths, implement the changes, and validate them against the full test suite plus a 348-case differential output-equality harness for interpolate. Every change was reviewed and benchmarked (before/after time and peak-RSS measurements) by the author.


Generated by Claude Code

getTypes() runs per module on a hot path via Module#getReferencedSourceTypes
(it bypasses the _sourceTypes cache because CSS types depend on incoming
connections). Track the two reachable source types as booleans instead of
allocating a Set per call, return the interned NO_TYPES for the empty case
so reference-equality comparison in Compilation stays stable, and stop
iterating once both flags are set.

Also pre-resolve whether localIdentName needs [hash]/[contenthash] once in
the CssGenerator constructor so getLocalIdent skips re-running the template
regexes on every export.
Resolve a function path first, then return early when the resulting string
contains no `[` placeholder. The replacement table (~15-20 closures) and the
regex pass are pure overhead for literal paths; replacer construction has no
side effects (assetInfo writes, deprecation warnings and missing-variable
throws all fire only when a replacer is invoked during replace), so the
output is unchanged.
Profiling a CSS-modules build showed WeakTupleMap.provide via
moduleGraph.cached at ~2.3% of nonlib JS, dominated by the
`() => fn(this, ...args)` thunk allocated on every call — including cache
hits, where provide returns before ever invoking it.

Add a closure-free WeakTupleMap.cachedProvide that takes the computer, its
`this`, and the key tuple directly, and route cached() through it. The cache
key layout ([fn, ...args]) and the undefined-value semantics are unchanged;
the hit path is ~3x cheaper and GC drops noticeably on export-heavy builds.
The JS-export branch built the used/convention name union with
`names.map(...).filter(Boolean)` plus `new Set([...usedNames, ...names])`,
allocating three throwaway arrays per export on a code-gen hot path
(~3% of nonlib JS in a CSS-modules build profile). Build the Set directly
with two short loops over `names`, preserving insertion order (used names
first) so emitted export order is unchanged.
…ired)

First step of collapsing the ~one-dependency-per-export CSS model into one
dependency per module. Introduces CssExportsDependency holding a flat array
of plain-object export entries (name/value/range/interpolate/exportMode/
exportType/loc) and ports the per-export logic into aggregate form:
getExports/getReferencedExports/getWarnings/updateHash/serialize plus a
Template that resolves @value/composes references through other modules'
consolidated entries.

Not yet wired into CssParser, so behaviour is unchanged; registered for
serialization and as a dependency template. The parser migration and
removal of the per-export dependency follow.
Wire the consolidated dependency into CssParser: the ~20 per-export
`new CssIcssExportDependency(...)` + `addDependency` sites now push plain
entries into a per-parse collector, and a single CssExportsDependency is
emitted at parse end. CssIcssSymbolDependency resolves through the new
template. This replaces ~one Dependency-per-export with one per module
(~48k -> ~600 objects on a CSS-heavy build) while keeping output identical
(full CSS suite + snapshots pass).

updateHash concatenates each entry's contribution with no separator so the
module hash byte stream matches the legacy per-export sequence (stable
hashes). Compilation now falls back to an error's own `loc` so the
consolidated dependency can attribute per-entry warning locations.
Add deterministic (mulberry32-seeded) fuzzing over escapeIdentifier,
unescapeIdentifier, equalsLowerCase and cssExportConvention with
edge-case input (control chars, lone surrogates, backslash escapes,
leading digit/hyphen). Guards against crashes and escape/unescape
round-trip violations; 70k seeded inputs currently pass clean.
The per-export dependency is no longer created since exports are consolidated
into CssExportsDependency. Move the still-used pieces (getLocalIdent,
computeInterpolatedIdentifier, and the EXPORT_MODE/EXPORT_TYPE enums) into
CssExportsDependency, repoint the ExportMode/ExportType typedefs, drop the
serialization and dependency-template registrations, and delete the file.
CssExportsDependency.apply and CssGenerator.generate looked up
moduleGraph.getExportInfo(module, name) per export, re-resolving
module -> ExportsInfo (a Map lookup) every time. Since every export of a
module shares one ExportsInfo, resolve it once and call getExportInfo(name)
on it. Cuts Map.get/has ticks ~13% on a CSS-modules build profile
(FindOrderedHashMapEntry 5.5% -> 4.9% of nonlib JS).
…rpolate

interpolate built the full ~15-entry replacer table on every call even though
a template (e.g. a CSS localIdentName like [name]__[local]__[hash:6]) usually
references a couple of placeholders. Scan the template for the placeholder
kinds it actually uses (cached per template string, bounded) and build only
those replacers. Output is byte-identical (validated across a 348-case
template x data matrix); cuts interpolate's Map.set ~43% and self-time ~40%
on a CSS-modules build, and helps output-path generation in all builds.
@changeset-bot

changeset-bot Bot commented Jun 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cc23edb

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 Jun 6, 2026

Copy link
Copy Markdown
Contributor

This PR is packaged and the instant preview is available (158f2d6).

Install it locally:

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

@codecov

codecov Bot commented Jun 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.44904% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.10%. Comparing base (28fbdce) to head (cc23edb).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
lib/dependencies/CssIcssExportDependency.js 98.91% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #21114      +/-   ##
==========================================
+ Coverage   91.99%   92.10%   +0.10%     
==========================================
  Files         581      581              
  Lines       61433    61486      +53     
  Branches    16787    16846      +59     
==========================================
+ Hits        56516    56631     +115     
+ Misses       4917     4855      -62     
Flag Coverage Δ
css-parsing 28.69% <19.00%> (+0.01%) ⬆️
html5lib 27.84% <16.80%> (-0.02%) ⬇️
integration 89.50% <92.83%> (+0.01%) ⬆️
test262 45.28% <12.94%> (-0.03%) ⬇️
unit 40.17% <54.54%> (+0.56%) ⬆️

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.

The CssGeneratorExportsConvention schema referenced the now-deleted
CssIcssExportDependency for its function tsType, breaking the generated
declarations (tsc TS2307). Move the ExportsConventionFn typedef to
CssExportsDependency, point the schema at it, and regenerate.
@codspeed-hq

codspeed-hq Bot commented Jun 6, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 28.74%

⚠️ 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

⚡ 3 improved benchmarks
❌ 2 regressed benchmarks
✅ 139 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory benchmark "css-modules", scenario '{"name":"mode-development","mode":"development"}' 756.6 KB 1,715.7 KB -55.9%
Memory benchmark "side-effects-reexport", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 595.3 KB 1,187.3 KB -49.86%
Memory benchmark "asset-modules-inline", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 1,292.2 KB 230.8 KB ×5.6
Memory benchmark "react", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 330.1 KB 151.8 KB ×2.2
Memory benchmark "asset-modules-bytes", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 322.6 KB 245.6 KB +31.36%

Tip

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


Comparing claude/css-modules-perf-optimization-I8d2z (cc23edb) with main (1c36fd6)

Open in CodSpeed

…ated dep

Rename CssExportsDependency back to CssIcssExportDependency (file, class,
Template, serialization id, and all references) so the consolidated
one-per-module export dependency keeps the established name instead of
introducing a new one. No behavior change.
…rpolate

Add unit tests for the consolidated CSS export dependency (type, getExports,
getReferencedExports, getWarnings, hash memoization, serialization round-trip)
and for path interpolation (filename/data-uri/chunk/module placeholders, legacy
aliases, escaped brackets, and the bounded present-kinds cache clear).
getPresentKinds used String.prototype.matchAll, which only exists on
Node.js 12+, so on the supported Node.js 10 range every templated path
interpolation threw and the integration suite hung. Switch to a
RegExp.exec loop, which is equivalent and available everywhere.
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Types Coverage

Coverage after merging claude/css-modules-perf-optimization-I8d2z into main will be
98.98%
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/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-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%
   CleanPlugin.js99.15%100%100%99.15%206, 226
   CodeGenerationResults.js100%100%100%100%
   CompatibilityPlugin.js100%100%100%100%
   Compilation.js98.49%100%100%98.49%1577, 1873, 1880, 1888, 1910, 2806, 3247, 3922, 3952, 4005–4006, 4010, 4015, 4031–4032, 4046–4047, 4052–4053, 4530, 4556, 512, 517, 5364, 5396, 5413, 5429, 5445, 5460, 5485–5486, 5488, 5816, 5821, 5827, 5830, 5842, 5844, 5848, 5864, 5879, 5911, 5965, 5989, 6103, 731–732
   Compiler.js99.56%100%100%99.56%1135–1136, 1144
   ConcatenationScope.js98.59%100%100%98.59%189
   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.js99%100%100%99%170–171, 187, 206, 280
   DependenciesBlock.js100%100%100%100%
   Dependency.js98.15%100%100%98.15%379, 425
   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.97%100%100%98.97%425–429, 577
   ExternalModuleFactoryPlugin.js100%100%100%100%
   ExternalsPlugin.js100%100%100%100%
   FileSystemInfo.js99.50%100%100%99.50%182, 2252–2253, 2256, 2267, 2278, 2289, 278, 3693, 3708, 3732
   FlagAllModulesAsUsedPlugin.js100%100%100%100%
   FlagDependencyExportsPlugin.js98.85%100%100%98.85%434, 436, 440
   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%
   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%1312, 1317, 1377, 1391, 1453, 1462
   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%659
   MultiStats.js100%100%100%100%
   MultiWatching.js100%100%100%100%
   NoEmitOnErrorsPlugin.js100%100%100%100%
   NodeStuffPlugin.js100%100%100%100%
   NormalModule.js98.15%100%100%98.15%1212, 1215, 1232, 1249, 1496, 1530, 1546, 1633, 2288, 2293–2303, 569
   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%519–520, 525, 527, 591
   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, 891
   Stats.js100%100%100%100%
   Template.js100%100%100%100%
   TemplatedPathPlugin.js99.13%100%100%99.13%176–177
   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%326
   cli.js98.62%100%100%98.62%10, 119, 545, 577, 627, 897
   index.js99.72%100%100%99.72%165
   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%
   AssetModulesPlugin.js97.32%100%100%97.32%283, 307, 310, 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/cache
   AddBuildDependenciesPlugin.js100%100%100%100%
   AddManagedPathsPlugin.js100%100%100%100%
   IdleFileCachePlugin.js97.92%100%100%97.92%71, 83, 91
   MemoryCachePlugin.js95.83%100%100%95.83%33
   MemoryWithGcCachePlugin.js93.15%100%100%93.15%106, 113–114, 122, 89
   PackFileCacheStrategy.js96.40%100%100%96.40%1250, 1350, 1354, 1416, 628, 647, 657–659, 661, 677–678, 683, 686, 688, 693, 698, 722, 728, 762, 768, 774, 779, 790, 799, 804–805, 807, 824, 830–831, 833
   ResolverCachePlugin.js100%100%100%100%
   getLazyHashedEtag.js100%100%100%100%
   mergeEtags.js100%100%100%100%
lib/config
   browserslistTargetHandler.js100%100%100%100%
   defaults.js99.30%100%100%99.30%1428–1430, 1438, 273, 276, 281, 285
   normalization.js99.01%100%100%99.01%191–192, 258, 273
   target.js100%100%100%100%
lib/container
   ContainerEntryDependency.js100%100%100%100%
   ContainerEntryModule.js100%100%100%100%
   ContainerEntryModuleFactory.js100%100%100%100%
   ContainerExposedDependency.js100%100%100%100%
   ContainerPlugin.js100%100%100%100%
   ContainerReferencePlugin.js100%100%100%100%
   FallbackDependency.js100%100%100%100%
   

@alexander-akait alexander-akait merged commit 158f2d6 into main Jun 8, 2026
66 checks passed
@alexander-akait alexander-akait deleted the claude/css-modules-perf-optimization-I8d2z branch June 8, 2026 10:07
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.

1 participant