fix(css): compile SCSS before LightningCSS minification#1393
fix(css): compile SCSS before LightningCSS minification#1393james-elicx wants to merge 2 commits into
Conversation
When a CSS module uses `composes: foo from './other.module.scss'`, postcss-modules' built-in `FileSystemLoader` reads the raw `.scss` text and feeds it straight into PostCSS, bypassing Vite's Sass preprocessor. The unprocessed source (with `$var: red;` declarations) then reaches Vite 8's lightningcss minifier — the default for server environments — which rejects it with "Invalid empty selector" and fails the build. This wires up a custom `css.modules.Loader` that runs Vite's `preprocessCSS` over `.scss`/`.sass`/`.less`/`.styl` files before postcss-modules sees them, mirroring how Next.js' webpack pipeline sequences sass-loader before css-modules. Closes #1343
commit: |
|
|
/bigbonk review |
There was a problem hiding this comment.
Review: fix(css): compile SCSS before LightningCSS minification
Good fix for a real and well-diagnosed bug. The approach of providing a custom postcss-modules Loader is the correct architectural choice given that Vite bundles postcss-modules internally and doesn't expose FileSystemLoader for extension. The documentation quality in the module header is excellent — it explains the problem, the solution, and the relationship to Next.js clearly.
A few issues to address:
Bug: require.resolve in ESM context
packages/vinext/src/plugins/css-modules-preprocess.ts:161
return require.resolve(unquoted);This file is part of an ESM package ("type": "module" in package.json). Bare require is not available in ESM modules. This will throw ReferenceError: require is not defined at runtime when a CSS module uses composes: foo from 'some-package/styles.module.css' (bare specifier resolution path).
This works in the test environment because Vite's build pipeline runs modules through its transform, but in production/built form this code ships as ESM. You need to use createRequire from node:module:
import { createRequire } from "node:module";
// ...
const require = createRequire(import.meta.url);
return require.resolve(unquoted);The rest of index.ts already uses createRequire for exactly this pattern (see lines 136, 160-161, 334).
Bug: String.prototype.replace only replaces the first occurrence
packages/vinext/src/plugins/css-modules-preprocess.ts:271
value = value.replace(key, translations[key]!);String.prototype.replace with a string pattern only replaces the first occurrence. If an export value contains multiple references to the same imported symbol, only the first will be substituted. This should use replaceAll:
value = value.replaceAll(key, translations[key]!);This is a subtle but real divergence from the built-in Parser's handleExport behavior, which uses icss-utils' replaceValueSymbols (which replaces all occurrences via regex).
Additionally, String.prototype.replace interprets special patterns like $&, $1, etc. in the replacement string. Since the replacement comes from CSS class names (which can contain $ after Sass processing), this could produce incorrect output. Using a replacement function avoids this:
value = value.replaceAll(key, () => translations[key]!);Nit: Promise.all on import processing is safe but worth a comment
packages/vinext/src/plugins/css-modules-preprocess.ts:226
The Promise.all for processing imports is correct here because each import resolves independently and writes to distinct translations[decl.prop] keys. The original FileSystemLoader processes imports sequentially, but the parallel approach is fine since the depNr trace key is pre-assigned. Just noting this is an intentional divergence from the original — might be worth a one-line comment.
Nit: User's css.modules options may be clobbered
packages/vinext/src/index.ts:1644-1658
The config hook returns css: { modules: { Loader: ... } }. Vite's config merging does a shallow merge of css.modules, so if a user also sets css.modules.scopeBehaviour or css.modules.generateScopedName in their vite.config.ts, the merge should work correctly since both sides contribute different keys. However, if a user already sets their own css.modules.Loader, vinext will silently override it. Consider checking for a user-supplied Loader and logging a warning, or skipping the override in that case.
Minor: Test robustness
The tests are well-structured and cover the two main Next.js test cases (composes-external, nm-module-nested). Two small observations:
-
The
findBundledCsshelper uses a depth-first search that returns the first.cssfile found. If Vite emits multiple CSS files (e.g., in future versions with different code-splitting), this could be fragile. Consider collecting all CSS files and asserting on the concatenated content, or asserting that at least one CSS file matches. -
Good use of
describeIfSassto skip when sass is unavailable. The test comment mentions this pattern matchestests/scss.test.ts, which is good for consistency.
Overall
The core approach is sound and well-researched. The require.resolve ESM issue is the most important fix needed — the replace/replaceAll issue is a correctness concern that may not trigger in typical use but should be fixed for robustness. Ship it after addressing those two.
|
Review posted on PR #1393. Here's a summary of the findings: Two bugs found:
Two nits:
Overall assessment: The core approach is correct and well-documented. The fix properly addresses the root cause (postcss-modules' FileSystemLoader bypassing Vite's Sass preprocessor). The two bugs should be fixed before merge, but neither blocks the happy path tested in the PR. |
- Use `createRequire` to call `require.resolve` from this ESM module (postcss-modules' `FileSystemLoader.fetch` does the same dance for bare module specifiers; bare `require` would throw at runtime). - Switch the `:export` value substitution to `replaceAll` with a function replacement so every occurrence is substituted and `$&`/ `$1`-style patterns in the replacement string aren't reinterpreted. - Detect a user-supplied `css.modules.Loader` and skip vinext's override, emitting a warning so the user knows the SCSS composes fix is opt-out. - Document the intentional `Promise.all` divergence from the built-in Parser. - Make the test's CSS lookup robust to multi-file emit by concatenating every `.css` file under the build output.
|
/bigbonk review |
There was a problem hiding this comment.
Re-review: all previous feedback addressed
Verified commit 6791600 against the four items from the prior review — all fixed correctly:
require.resolvein ESM — Fixed.createRequire(import.meta.url)at module scope (line 78), used asnodeRequire.resolve()at line 170.replace→replaceAll— Fixed. Line 297 usesreplaceAllwith a function replacement to avoid$&/$1interpretation. Well-documented in the comment block (lines 284-290).Promise.alldivergence — Documented at lines 236-243.- User-supplied Loader override — Detected and warned at lines 1302-1320 in index.ts, with the override skipped.
- Test
findBundledCssfragility — Replaced withreadAllBundledCsswhich concatenates all.cssfiles.
Deep re-review of updated code
Looked closely at several areas that weren't covered in the first review:
-
substitutefunction correctness — ThetokenReregex shares state acrosssubstitutecalls fromroot.walk(), but this is safe:walk()is synchronous (PostCSSContainer.each), andlastIndexis explicitly reset to 0 on each call. ThelastIndexadjustment formula correctly positions the cursor atm.index + replacement.lengthregardless of whether the replacement is shorter, longer, or equal in length. This is a faithful port oficss-utilsreplaceValueSymbols, with a defensive improvement (explicitlastIndexreset that the original lacks). -
traceKeySorter— Character-identical to the original postcss-modules implementation. The asymmetric<vs<=operators match. -
postcss version alignment — Both vinext and Vite resolve to the same
postcss@8.5.10instance at runtime. The bundled postcss-modules plugins__require("postcss")at runtime, so AST nodes created by vinext's Loader are fully compatible. -
Bare specifier resolution anchor —
nodeRequireis anchored at vinext's source directory viacreateRequire(import.meta.url), which mirrors the originalFileSystemLoaderbehavior (anchored at Vite's dist directory via__require). Both fall back topath.resolve(root, relativeDir, specifier)whenrequire.resolvefails, which is the correct user-project-relative resolution. Not a regression. -
Config lifecycle — The
getResolvedConfigclosure correctly threads the resolved config (captured inconfigResolved) to the Loader (constructed inconfig()). The#readSourceguard handles the edge case. -
Both Loader installation paths (lines 1635-1692) correctly handle the presence/absence of
postcssOverride/sassPreprocessorOptions, ensuring the Loader is always installed.
Clean. Ship it.
|
Review posted on PR #1393. Here's a summary: All four items from the previous review have been addressed correctly in commit
Deep re-review found no new issues. Verified:
Approved. |
Summary
composes: foo from './other.module.scss', postcss-modules' built-inFileSystemLoaderreads the raw.scsstext and feeds it straight into PostCSS, bypassing Vite's Sass preprocessor.$var: red;declarations) then reaches Vite 8's lightningcss minifier — the default for server environments — which rejects it withSyntaxError: [lightningcss minify] Invalid empty selectorand fails the build.css.modules.Loaderthat runs Vite'spreprocessCSSover.scss/.sass/.less/.stylfiles before postcss-modules sees them. The Loader reimplements the smallFileSystemLoader+ Parser logic from postcss-modules (which Vite bundles internally and we cannot import directly), but interposes a preprocessor step between the file read and the PostCSS pipeline.test/e2e/app-dir/scss/composes-externalandtest/e2e/app-dir/scss/nm-module-nested.Test plan
pnpm test tests/scss-composes.test.ts— new regression tests for both thecomposes-externalandnm-module-nestedpatterns (skipped ifsassnot installed locally; install withpnpm add -D sass -wto exercise them).pnpm test tests/scss.test.ts tests/sass-options.test.ts tests/nextjs-compat/app-css.test.ts tests/ssr-css-assets.test.ts— existing CSS suite still passes.vp checkclean on the new files.Closes #1343