Skip to content

CSS modules: webpack/css-loader-compatible scoped-name hashing#1237

Open
sciyoshi wants to merge 14 commits into
parcel-bundler:masterfrom
fellowapp:feat/css-modules-vite-parity
Open

CSS modules: webpack/css-loader-compatible scoped-name hashing#1237
sciyoshi wants to merge 14 commits into
parcel-bundler:masterfrom
fellowapp:feat/css-modules-vite-parity

Conversation

@sciyoshi

@sciyoshi sciyoshi commented May 5, 2026

Copy link
Copy Markdown
Contributor

Adds opt-in support for producing CSS-modules scoped names byte-identical to webpack/css-loader and Vite-via-postcss-modules output. Default [hash] behavior is unchanged.

Reverse-engineered the algorithm from loader-utils.getHashDigest + loader-utils.interpolateName + genericNames against ground-truth output captured from a real Vite/postcss-modules build. End-to-end byte-parity verified across 12 captured class names exercising every code path (including the leading-digit / leading--digit / leading--- prefix branches).

Closes #503, #636, #660, #351. Partially addresses #491 ([hash] truncation only).

New pattern syntax

Webpack-style [<algo>:hash:<digest>:<length>] placeholders. Each part is independently optional; the position of the literal hash keyword distinguishes algo (before) from digest+length (after):

Pattern Algorithm Digest Length
[hash] legacy lightningcss legacy n/a
[hash:base64] xxhash64 (default) base64 full
[hash:5] xxhash64 hex (default) 5
[md4:hash:base64:5] md4 base64 5
[xxhash64:hash:hex:12] xxhash64 hex 12

Bare [hash] parses to all-None and stays on the legacy code path — existing snapshots are byte-identical. Parsing is case-insensitive. Unknown algo, unknown digest, missing hash keyword, and extra parts before hash all return PatternParseError::UnknownPlaceholder.

Segment::ContentHash is intentionally left as a unit variant in this PR — same options on [content-hash] would require plumbing through the bundler's content-hash precomputation and is a follow-up.

New Config fields

Three new opt-in fields on css_modules::Config, all defaulting to no-op so existing output is unchanged:

Field Default Effect when set
hash_prefix: Option<Cow<'i, str>> None Prepended to every hash input. "\x00\x00\x00\x00" reproduces css-loader's tier-0 salt input byte-for-byte.
hash_local_name: bool false Appends the local name (separated by NUL) to each hash input. Matches css-loader/postcss-modules per-(file, local) hashing rather than lightningcss's per-file hashing.
escape_scoped_names: bool false Post-processes every rendered scoped name through loader-utils' genericNames rules: replace any char outside [a-zA-Z0-9\-_ -�] with -, then prefix _ when the result starts with -?[0-9] or --. Required for standard-base64 digests like [md4:hash:base64:5] to produce valid CSS identifiers.

Each is independently meaningful, but for css-loader byte-parity they travel together with a webpack-style pattern. The byte-parity end-to-end test in tests::css_modules_webpack_parity exercises all four together against the captured Vite output.

Public API additions

  • pub enum HashAlgorithm { Md4, Xxhash64 }
  • pub enum DigestType { Hex, Base64 }
  • Segment::Hash becomes a struct variant: { algo: Option<HashAlgorithm>, digest: Option<DigestType>, length: Option<usize> }
  • Config { hash_prefix, hash_local_name, escape_scoped_names }
  • pub(crate) fn hash_with_options(input: &[u8], algo, digest, length) -> String (used by both the new pattern path and the byte-parity test)

JS API additions

CSSModulesConfig in node/index.d.ts and napi::CssModulesConfig gain three optional camelCase fields (hashPrefix, hashLocalName, escapeScopedNames) that thread through to the Rust Config. Defaults preserve existing JS-side behavior.

This is what makes the feature reachable from Vite via css.lightningcss.cssModules.

Why

CSS-modules ecosystem hashing — used by webpack/css-loader, postcss-modules, and Vite-with-PostCSS — is genuinely opinionated, but it's also the de facto convention. Lightningcss's existing [hash] is more lightweight (per-file, custom alphabet, no salt, no post-processing) which is fine for a fresh project, but doesn't let anyone migrate from PostCSS to lightningcss without changing every scoped name in the build. That's a hard sell for production codebases.

The four pieces here are deliberately independent flags rather than a single compat_mode bundle, on the principle that the API should be honest about which knob does what. A user who only needs salting can enable hash_prefix without buying into per-local hashing or escaping. For full webpack parity all four go on at once.

Backward compatibility

  • Default [hash] is unchanged. The legacy code path is preserved when algo, digest, and length are all None.
  • All three new Config fields default to None / false. No existing user sees different output until they opt in.
  • Existing tests pass byte-identical (verified locally — cargo test --no-default-features shows only the pre-existing platform-dependent tests::test_dependencies SipHash snapshot failure, unrelated to this work).

Test plan

  • cargo test --no-default-features — 135 pass
  • Byte-parity end-to-end test (tests::css_modules_webpack_parity) reproduces 12 captured Vite/postcss-modules class names exactly. Cases cover every post-process branch:
    • Clean digest (Alpha-module__foo__YTbdH)
    • + and / cleanup (Alpha-module__cls_4__LOY-5, Alpha-module__cls_48__-ta-0)
    • Leading-digit prefix (digest leads → _2kP0r__…)
    • Leading -digit prefix (digest +0F5/-0F5-_-0F5-__…)
    • Leading -- prefix (digest //GdO--GdO_--GdO__…)
    • Leading -letter (no prefix)
  • 26 new unit tests in css_modules::tests covering parser forms, rejection cases, hash dispatch, and each post-process branch in isolation

Commits

  1. chore(css-modules): add md4 and xxhash-rust dependencies
  2. feat(css-modules): add HashAlgorithm and DigestType with hash_with_options helper
  3. feat(css-modules): extend Segment::Hash with optional algo, digest, and length
  4. feat(css-modules): parse webpack-style [<algo>:hash:<digest>:<length>] syntax
  5. feat(css-modules): add Config::hash_prefix for hash input salting
  6. feat(css-modules): add Config::hash_local_name for per-local hashing
  7. feat(css-modules): add Config::escape_scoped_names post-process pipeline
  8. test(css-modules): add Vite scoped-name byte-parity end-to-end test
  9. feat(napi): expose hash_prefix, hash_local_name, escape_scoped_names to JS

@devongovett

Copy link
Copy Markdown
Member

but doesn't let anyone migrate from PostCSS to lightningcss without changing every scoped name in the build

Not necessarily opposed to adding these, but isn't the whole point of CSS modules that the scoped names are not predictable?

@sciyoshi

Copy link
Copy Markdown
Contributor Author

Not necessarily opposed to adding these, but isn't the whole point of CSS modules that the scoped names are not predictable?

Indeed. However, in the real world there are situations where things outside of one's control depend on these classnames. In our case specifically, we have an extensive automated test suite which relies on these classnames to select elements. While we're doing work in parallel to remove that reliance on these mangled names, this approach is more straightforward and unblocks us from switching to LightningCSS.

Comment thread node/index.d.ts Outdated
* standard-base64 digests like `[md4:hash:base64:5]` to produce valid CSS
* identifiers. Default: false.
*/
escapeScopedNames?: boolean

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is this an option? Won't false produce invalid CSS? Shouldn't it default to true?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I removed escapeScopedNames as a public option instead of just flipping the default.

The post-process now runs automatically whenever the pattern uses the extended legacy webpack/css-loader-compatible hash syntax, e.g. [md4:hash:base64:5]. Bare [hash] still keeps Lightning CSS’s default hash path unchanged.

Added a regression test asserting extended/base64 hash patterns are escaped automatically. Done in baffdb0.

Comment thread src/css_modules.rs Outdated
},
)
.unwrap();
let name = self.maybe_escape(body);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we keep this logic inside the or_insert_with so we only do it if the entry doesn't already exist?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, fixed in d112c8d.

Looking at this again, the printer had already rendered the scoped name it writes to CSS, and then add_local rendered the same name again for the exports map. I changed add_local to take the printer’s already-computed scoped name and use or_insert_with, so the helper no longer repeats the pattern rendering work.

Comment thread src/css_modules.rs Outdated
},
)
.unwrap();
let name = format!("--{}", self.maybe_escape(body));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed this one in the same commit, d112c8d.

write_dashed_ident now passes the already-computed scoped custom-property name into add_dashed, and add_dashed uses or_insert_with. While I was there, I also cleaned up the local reference_dashed path so it checks for an existing entry before rendering a new scoped name.

Comment thread src/css_modules.rs
/// rules: replace any char outside `[a-zA-Z0-9\-_]` (plus the latin-1+ unicode range
/// `U+00A0..=U+FFFF`) with `-`, then prefix `_` when the result starts with `-?[0-9]`
/// or `--` so the output remains a valid CSS identifier.
pub(crate) fn escape_scoped_name(s: &str) -> String {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The Printer already uses serialize_identifier / serialize_name in its write_ident method, which is what calls css_module.config.pattern.write. Those functions already escape the name, so I don't think we need to implement a custom escaping function here as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are doing different things, but I should have called that out. Added a doc comment in ea0243f.

serialize_identifier / serialize_name make a string valid CSS syntax by backslash-escaping at print time. escape_scoped_name rewrites the literal scoped name to match legacy css-loader/postcss-modules output.

For example, serializer escaping would make foo+bar printable as foo\+bar in CSS, but the JS exports map would still have the literal foo+bar. The compatibility post-process instead rewrites both sides to the same literal name, e.g. foo-bar. The printer still serializes the rewritten name afterward for general CSS safety.

Comment thread node/index.d.ts
* Required to match css-loader/postcss-modules' per-(file, local) hashing.
* Default: false (lightningcss hashes once per file).
*/
hashLocalName?: boolean,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Lightningcss already supports [contenthash] which does this. Seems confusing to have two ways of doing the same thing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I see why these look related, but they’re solving different problems. I clarified the docs in 4f76fcc.

[content-hash] is derived from file contents, so every export in a given module sees the same content hash. hashLocalName changes the input to the [hash] segment to include the local export name: <prefix><path>\0<local>. That means each export in the same file can get a different hash, which is what css-loader/postcss-modules do.

sciyoshi added 14 commits May 17, 2026 20:54
These will back new HashAlgorithm options in the next commit, enabling
webpack/css-loader-compatible scoped-name hashes.
…tions helper

Introduces a parameterized hash-and-encode helper for CSS module names, with
md4 and xxhash64 algorithms and standard-base64 / hex digests. The default
[hash] / [content-hash] code path is unchanged; this commit only adds the new
types and helper. Pattern parsing and Segment wiring follow in subsequent
commits.

Tested against ground-truth output captured from a Vite/postcss-modules build
to confirm md4+base64+truncate behaves identically to webpack's
loader-utils.getHashDigest.
…nd length

Segment::Hash becomes a struct variant carrying optional HashAlgorithm,
DigestType, and length fields. When all are None the legacy siphash +
custom-base64 path is preserved (existing test snapshots unchanged); when any
is Some, hashing dispatches through hash_with_options.

Hash computation moves from CssModule::new to Pattern::write so each segment
can apply its own options. CssModule::hashes is renamed to hash_inputs and
now stores the raw source-relative path string instead of a precomputed
hash. Pattern::write computes the hash per segment from this raw input.

Segment::ContentHash remains a unit variant in this commit; per-segment
options on [content-hash] would require additional plumbing through the
bundler's content-hash precomputation and is left for a follow-up.
…] syntax

The parser now recognizes webpack-compatible scoped-name hash placeholders.
Each of algo, digest, and length is optional; the position of the literal
\`hash\` keyword distinguishes algo (before) from digest+length (after).
Examples:

  [hash]                  -> legacy lightningcss hash, unchanged
  [hash:base64]           -> default algo, base64 digest, full length
  [hash:5]                -> default algo, hex digest, 5 chars
  [md4:hash:base64:5]     -> md4 + base64 + 5 chars (matches webpack)
  [xxhash64:hash:hex:12]  -> xxhash64 + hex + 12 chars

Bare \`[hash]\` keeps the legacy code path so existing snapshots remain byte-
identical. Recognized algorithms: md4, xxhash64. Recognized digests: hex,
base64. Parsing is case-insensitive. Unknown algos/digests, missing \`hash\`
keyword, and extra parts before \`hash\` all return UnknownPlaceholder.

Tests cover each form, case-insensitivity, rejection of bad input, and a
round-trip integration test against ground-truth output captured from a
Vite/postcss-modules build (digest only; the css-loader content composition
and post-processing pipeline live downstream of this commit).
Adds a hash_prefix field to css_modules::Config that prepends a string to
every hash input. This matches Vite/postcss-modules' hashPrefix option and,
when set to '\x00\x00\x00\x00', reproduces css-loader's tier-0 salt input
byte-for-byte — a necessary piece for Vite-to-webpack scoped-name parity.

Default is None, leaving lightningcss output unchanged for existing users.

Per-local hash composition and post-processing (also required for full Vite
parity) follow in subsequent commits.
Adds a hash_local_name flag to css_modules::Config. When true, the local
class/ident name is appended to the hash input separated by a NUL byte:
<prefix><relative-path>\0<local>. This matches the per-local hashing done
by css-loader and postcss-modules — without it every export from a given
file shares one hash.

Composition happens via a new CssModule::hash_input_for helper, called from
each Pattern::write call site (add_local, add_dashed, reference,
reference_dashed, handle_composes, plus printer's write_ident and
write_dashed_ident). Each call allocates a small string when hash_local_name
is enabled; otherwise it returns Cow::Borrowed against the existing buffer.

Default is false, preserving lightningcss's per-file hashing for users who
don't opt in. Combined with hash_prefix from the previous commit, the bytes
fed to the hash now match css-loader's input bytes exactly.

A test asserts that Md4+Base64+5 over the composed input reproduces the
captured Vite digest 'YTbdH' for src/styles/Alpha.module.css#foo.
Adds an escape_scoped_names config flag and an escape_scoped_name helper
that mirrors css-loader/postcss-modules' genericNames post-processing:

  s.replace(/[^a-zA-Z0-9\\-_\\u00A0-\\uFFFF]/g, '-')
   .replace(/^((-?[0-9])|--)/, '_\$1')

The pipeline runs after Pattern::write_to_string at every scoped-name call
site (CssModule::add_local/add_dashed/reference/reference_dashed/handle_composes
plus Printer::write_ident/write_dashed_ident). For dashed (custom property)
idents the leading '--' is excluded — the rendered pattern is escaped
without the prefix and '--' is prepended afterwards, so '--' on a custom
property never triggers the leading-double-dash rule.

write_to_string is also promoted from private to pub(crate) so the printer
can call it directly (replacing the previous streaming Pattern::write +
serialize_identifier/serialize_name closure pattern). Output is unchanged
for the legacy (no-escape) path: serialize_identifier on a full string
produces the same bytes as serialize_identifier on the head + serialize_name
on the tail.

Eight unit tests cover the four post-process branches captured from the
real Vite/postcss-modules build (digit, +, /, leading -digit, leading --,
leading -letter unchanged, clean input, unicode above U+00A0).
Integrates the four hashing features (md4/xxhash64 algos, hash_prefix,
hash_local_name, escape_scoped_names) and asserts the rendered scoped names
match a Vite/postcss-modules build's output byte-for-byte, using the
generateScopedName='[name]__[local]__[md4:hash:base64:5]' and
hashPrefix='\\0\\0\\0\\0' configuration captured from a real project.

Twelve cases across two source files cover every post-processing branch:
clean digest, +/-/-cleanup-mid-name, leading-digit prefix on rendered output,
leading -digit prefix, leading -- prefix, and leading -letter (no prefix).

If this test breaks, lightningcss has diverged from webpack/css-loader
hashing and Vite migrations relying on hash stability will produce different
class names than they did pre-migration.
…to JS

Threads the three Fellow-fork CssModulesConfig fields through the napi
binding so they're reachable from the @fellowapp/lightningcss JS API:

- napi::CssModulesConfig gains hashPrefix, hashLocalName, escapeScopedNames
  (camelCase via serde rename_all on the parent struct).
- Both compile() and bundle() Config initializations now wire these through
  to lightningcss::css_modules::Config.
- node/index.d.ts CSSModulesConfig type extended with the same three optional
  fields, with rustdoc-equivalent JSDoc explaining each.

Without this commit lightningcss-napi fails to compile against the new
lightningcss::css_modules::Config struct (E0063 missing fields). Verified
locally with cargo build -p lightningcss-napi --release.
@sciyoshi sciyoshi force-pushed the feat/css-modules-vite-parity branch from 095d5b9 to 4f76fcc Compare May 18, 2026 02:10
@sciyoshi

Copy link
Copy Markdown
Contributor Author

Thanks so much for taking the time to review this; much appreciated, especially given the size of the diff.

I pushed a few small follow-up commits addressing the inline comments:

  • 0791502 clarifies the “legacy” terminology so Lightning CSS’s existing behavior is described as the default/native path, and legacy compatibility refers to webpack/css-loader/postcss-modules.
  • baffdb0 removes escapeScopedNames as a public option and applies the compatibility post-process automatically for extended hash patterns.
  • d112c8d avoids re-rendering scoped names in add_local / add_dashed.
  • ea0243f documents why escape_scoped_name is distinct from CSS serializer escaping.
  • 4f76fcc clarifies how hashLocalName differs from [content-hash].

I also rebased this on the current master branch to fix the merge conflict around CSS module pattern ownership (master moved Pattern / Segment to an owned model without lifetimes).

Separately: full webpack/css-loader parity was the bar we wanted for our own migration. I completely understand if that scope is more than you want to maintain long-term. If any of this feels like too much new config or compatibility behavior to maintain, we’re happy to keep it in a fork for our internal use and you can take only the pieces that feel useful to LightningCSS, or none of it. No pressure to merge as-is; your call, and thanks again for the thoughtful review.

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.

Is it possible to change hash function for css modules?

2 participants