perf(css): reduce CSS build time and memory usage#21114
Conversation
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 detectedLatest commit: cc23edb The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
This PR is packaged and the instant preview is available (158f2d6). Install it locally:
npm i -D webpack@https://pkg.pr.new/webpack@158f2d6
yarn add -D webpack@https://pkg.pr.new/webpack@158f2d6
pnpm add -D webpack@https://pkg.pr.new/webpack@158f2d6 |
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
Merging this PR will improve performance by 28.74%
|
| 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)
…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.
Summary
CSS-modules builds create roughly one
Dependencyinstance per:exportentry (tens of thousands on class-heavy projects), which inflates time and retained memory. This PR consolidates them into a singleCssExportsDependencyper module (a flat array of plain-object entries) and trims hot-path allocations/lookups in CSS code generation,ModuleGraph.cached, andTemplatedPathPlugin.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/interpolatewins.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 intest/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