Files
cloudflare__vinext/tests/scss-composes.test.ts
T
James Anderson ce29f9c6aa fix(scss): preprocess SCSS CSS-module composes deps via SassAwareFileSystemLoader (#1882)
* fix(scss): preprocess SCSS CSS-module composes deps via SassAwareFileSystemLoader

postcss-modules' built-in FileSystemLoader reads files referenced by
`composes: className from './other.module.scss'` as raw text — without
Sass preprocessing.  For .scss files this leaves SCSS variables and bare
@import directives in the CSS output, causing LightningCSS to crash with
"Invalid empty selector" during production builds.

Fix: introduce SassAwareFileSystemLoader that delegates to Vite's
`preprocessCSS` API for every `composes:` dependency, so Sass compilation
runs before CSS-module scoping.  Register it via `css.modules.Loader` in
vinext's Vite config hook and thread the resolved config through via a new
`setSassLoaderResolvedConfig` call in the `configResolved` hook.

Fixes the following deploy-suite tests (both sass and sass-embedded variants):
- test/e2e/app-dir/scss/nm-module/nm-module.test.ts
- test/e2e/app-dir/scss/nm-module-nested/nm-module-nested.test.ts
- test/e2e/app-dir/scss/composes-external/composes-external.test.ts

Part of #1825 (non-tilde CSS-module/composes cases; complements #1881)

* fix(scss): address review feedback on SassAwareFileSystemLoader

- Guard the css.modules.Loader injection: respect a user's explicit
  css.modules: false (mergeConfigRecursively would otherwise re-enable
  CSS Modules with an object override) and never clobber a
  user-provided custom Loader. The override is precomputed as a typed
  const so the conditional spread doesn't disturb the config literal's
  contextual typing.
- Surface composes preprocessing failures via config.logger.warn
  instead of a bare catch {}, so missing composed classes are
  observable; a missing/unreadable dependency file now rejects like
  postcss-modules' FileSystemLoader did.
- Use Vite's exported preprocessCSS + PreprocessCSSResult type via a
  top-level import instead of the lazy dynamic import and stringly
  typed casts.
- Preserve parity for composes from non-*.module.* files: hand
  preprocessCSS a virtual *.module.* filename so plain '.css' deps are
  still scoped and export tokens extracted, matching the built-in
  FileSystemLoader.
- Port the three failing Next.js fixtures (composes-external,
  nm-module, nm-module-nested) as production-build regression tests in
  tests/scss-composes.test.ts, plus a plain .module.css / non-module
  .css parity test. All three SCSS tests reproduce the original
  "[lightningcss minify] Invalid empty selector" crash when the
  Loader injection is disabled.
- Add sass as a root devDependency so the SCSS tests (including the
  previously always-skipped tests/scss.test.ts) actually run in CI;
  drop the now-redundant knip ignore entry. Installing sass makes
  Vite's SassPreprocessorOptions resolve to the concrete sass types
  (previously any), so the tilde-importer's importers array now
  carries an explicit SassPreprocessorOptions["importers"] cast.

* fix(scss): bind composes-loader config per plugin instance; scope extensionless deps

- Replace the module-level resolved-config singleton with a per-vinext-
  instance binding (createSassAwareFileSystemLoader), so multiple builds in
  one process never preprocess composes deps with another build's config.
- Cover extensionless composes deps in the virtual *.module.* rename
  (append .module.css) so their tokens are scoped instead of silently
  dropped; add a parity test case.
- Pin url() pass-through behaviour for composed deps in the parity test
  (verbatim, never mangled into placeholders — matches the built-in
  FileSystemLoader, verified against a vanilla Vite build).

* docs(scss): document the per-subtree recursion boundary of the composes loader

Notes the two structural consequences of delegating nested composes
recursion to preprocessCSS (per-subtree duplicate inlining collapsed by
LightningCSS, and no cross-boundary cycle short-circuit for invalid
circular composes), per review.

* fix(scss): fail the build on composed-dep preprocessing errors

Only a missing Sass implementation is downgraded to a logged warning;
any other preprocessCSS error (e.g. a Sass syntax error in the composed
dependency) now propagates and fails the build, matching postcss-modules'
built-in FileSystemLoader instead of shipping a green build with
silently-missing classes. Docstrings updated to accurately describe the
silent empty-token path when no resolved config is bound. Adds a
regression test for the fail-loudly path.

* chore(deps): move sass to the workspace catalog; drop the now-dead dynamic skip in scss.test.ts

Review feedback on #1882: sass belongs in the pnpm workspace catalog
rather than pinned inline in the root package.json. With sass now a
guaranteed root devDependency, the runtime describeIfSass skip in
tests/scss.test.ts is dead code — the suite always runs — so make the
unskip explicit by removing the conditional machinery.

* rebuild lockfile
2026-06-11 00:29:51 +00:00

290 lines
12 KiB
TypeScript

/**
* CSS-module `composes: <class> from '<file>'` dependencies — SCSS and CSS.
*
* postcss-modules' built-in `FileSystemLoader` reads `composes` dependencies
* as raw text and pipes them straight into the CSS-module scoping plugins —
* without Sass preprocessing. For `.scss`/`.sass` dependencies this leaves
* SCSS syntax (`$var: red;`, bare `@import 'file.scss'`) verbatim in the
* output CSS, and LightningCSS then crashes during production minification
* with "Invalid empty selector".
*
* vinext replaces the loader with `SassAwareFileSystemLoader`
* (packages/vinext/src/plugins/sass.ts), which preprocesses every `composes`
* dependency through Vite's `preprocessCSS` (Sass compilation + CSS-module
* scoping in one shot, using the build's resolved config).
*
* Ported from the Next.js fixtures whose production builds crashed without
* the fix:
* test/e2e/app-dir/scss/composes-external/composes-external.test.ts
* test/e2e/app-dir/scss/nm-module/nm-module.test.ts
* test/e2e/app-dir/scss/nm-module-nested/nm-module-nested.test.ts
* https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir/scss
*
* Also covers plain `.module.css` → `.module.css` composes (no Sass) and
* composing from a non-`*.module.*` file, since the custom loader now runs
* for every CSS module — those paths must keep parity with the previous
* behaviour.
*
* Relates to: https://github.com/cloudflare/vinext/issues/1825
*/
import { describe, it, expect } from "vite-plus/test";
import { build } from "vite-plus";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import vinext from "../packages/vinext/src/index.js";
const ROOT_NODE_MODULES = path.resolve(import.meta.dirname, "../node_modules");
/**
* Materialize a Pages Router fixture in a fresh tmpdir.
*
* Unlike tests that symlink the workspace `node_modules` directory wholesale,
* this creates a real `node_modules` dir with per-entry symlinks so fixtures
* can add fake packages (e.g. `example`, mirroring the Next.js `nm-module`
* fixtures) alongside the real ones — entries under `node_modules/<pkg>/...`
* in the `files` map are written as real files.
*/
async function makeFixture(files: Record<string, string>): Promise<string> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-scss-composes-"));
const nmDir = path.join(tmpDir, "node_modules");
await fs.mkdir(nmDir);
for (const entry of await fs.readdir(ROOT_NODE_MODULES)) {
// Skip pnpm-internal entries (.pnpm, .bin, .modules.yaml, ...). Package
// symlinks resolve through their realpath into the workspace `.pnpm`
// store, so only the top-level package entries are needed.
if (entry.startsWith(".")) continue;
await fs.symlink(path.join(ROOT_NODE_MODULES, entry), path.join(nmDir, entry), "junction");
}
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(tmpDir, rel);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, content);
}
return tmpDir;
}
/**
* Production client build (the LightningCSS minification step that crashed
* with "Invalid empty selector" runs here). Returns the emitted CSS and JS
* concatenated for assertions.
*/
async function buildAndCollect(tmpDir: string): Promise<{ css: string; js: string }> {
const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-scss-composes-out-"));
try {
await build({
root: tmpDir,
configFile: false,
plugins: [vinext({ disableAppRouter: true })],
logLevel: "silent",
build: {
outDir: path.join(outDir, "client"),
manifest: true,
ssrManifest: true,
rollupOptions: { input: "virtual:vinext-client-entry" },
},
});
let css = "";
let js = "";
const entries = await fs.readdir(path.join(outDir, "client"), {
withFileTypes: true,
recursive: true,
});
for (const entry of entries) {
if (!entry.isFile()) continue;
const parent =
(entry as { parentPath?: string; path?: string }).parentPath ??
(entry as { path?: string }).path ??
outDir;
const full = path.join(parent, entry.name);
if (entry.name.endsWith(".css")) css += (await fs.readFile(full, "utf8")) + "\n";
if (entry.name.endsWith(".js")) js += (await fs.readFile(full, "utf8")) + "\n";
}
return { css, js };
} finally {
await fs.rm(outDir, { recursive: true, force: true }).catch(() => {});
}
}
const PAGE = [
'import styles from "../styles/index.module.scss";',
"export default function Home() {",
" return <div className={styles.subClass}>composes</div>;",
"}",
"",
].join("\n");
describe("SCSS CSS-module composes (production build)", () => {
it("composes from an external .module.scss containing SCSS syntax (composes-external)", async () => {
const tmpDir = await makeFixture({
"pages/index.tsx": PAGE,
"styles/index.module.scss":
".subClass {\n composes: className from './other.module.scss';\n}\n",
"styles/other.module.scss": "$var: red;\n.className {\n background: $var;\n}\n",
});
try {
// Without SassAwareFileSystemLoader this build rejects with
// "[lightningcss minify] Invalid empty selector" because `$var: red;`
// reaches the minifier verbatim.
const { css, js } = await buildAndCollect(tmpDir);
// The SCSS variable must be resolved, and the composed class scoped.
expect(css).not.toContain("$var");
expect(css).toMatch(/_className_[\w-]+/);
expect(css).toContain("background:red");
// Class composition: `styles.subClass` resolves to both scoped names.
expect(js).toMatch(/_subClass_[\w-]+ _className_[\w-]+/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 60_000);
it("composes from a .module.scss inside node_modules (nm-module)", async () => {
const tmpDir = await makeFixture({
"pages/index.tsx": PAGE,
"styles/index.module.scss":
".subClass {\n composes: className from 'example/index.module.scss';\n}\n",
"node_modules/example/package.json": JSON.stringify({ name: "example", version: "1.0.0" }),
"node_modules/example/index.module.scss":
"$var: red;\n.className {\n background: $var;\n}\n",
});
try {
const { css, js } = await buildAndCollect(tmpDir);
expect(css).not.toContain("$var");
expect(css).toMatch(/_className_[\w-]+/);
expect(css).toContain("background:red");
expect(js).toMatch(/_subClass_[\w-]+ _className_[\w-]+/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 60_000);
it("compiles nested Sass @import inside a composed node_modules .module.scss (nm-module-nested)", async () => {
const tmpDir = await makeFixture({
"pages/index.tsx": PAGE,
"styles/index.module.scss":
".subClass {\n composes: other2 from 'example/other.module.scss';\n color: blue;\n}\n",
"node_modules/example/package.json": JSON.stringify({ name: "example", version: "1.0.0" }),
"node_modules/example/other.module.scss":
"@import 'other3.scss';\n\n$var: red;\n\n.other2 {\n color: $var;\n}\n",
"node_modules/example/other3.scss": ".other3 {\n background: orange;\n}\n",
});
try {
const { css, js } = await buildAndCollect(tmpDir);
// Sass must have resolved both the variable and the nested @import —
// neither may survive into the emitted CSS.
expect(css).not.toContain("$var");
expect(css).not.toContain("@import");
expect(css).toMatch(/_other2_[\w-]+/);
expect(css).toContain("color:red");
// The @import-ed file's class is compiled (and scoped) into the output.
expect(css).toMatch(/_other3_[\w-]+/);
expect(css).toContain("background:orange");
expect(js).toMatch(/_subClass_[\w-]+ _other2_[\w-]+/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 60_000);
it("fails the build when a composed dependency has a Sass error", async () => {
const tmpDir = await makeFixture({
"pages/index.tsx": PAGE,
"styles/index.module.scss":
".subClass {\n composes: className from './broken.module.scss';\n}\n",
// `$undefined` is never declared — Sass compilation of the composed
// dependency must throw, and the loader must propagate it (only a
// missing Sass implementation is downgraded to a warning). A green
// build with silently-missing classes would be worse than the error.
"styles/broken.module.scss": ".className {\n background: $undefined;\n}\n",
});
try {
await expect(buildAndCollect(tmpDir)).rejects.toThrow(/broken\.module\.scss|Undefined/i);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 60_000);
});
describe("plain CSS-module composes parity (production build)", () => {
it("keeps `composes` working for .module.css, non-module .css, and extensionless dependencies", async () => {
const tmpDir = await makeFixture({
"pages/index.tsx": [
'import styles from "../styles/index.module.css";',
"export default function Home() {",
" return (",
" <div className={styles.subClass}>",
" <span className={styles.fromPlain}>composes</span>",
" <span className={styles.fromExtless}>extensionless</span>",
" </div>",
" );",
"}",
"",
].join("\n"),
"styles/index.module.css": [
".subClass {",
" composes: className from './other.module.css';",
" padding: 5px;",
"}",
".fromPlain {",
// postcss-modules treats every `composes` dependency as a CSS module
// regardless of filename; plain `.css` deps must keep working too.
" composes: plainClass from './plain.css';",
"}",
".fromExtless {",
// Extensionless deps resolve to a literal file on disk and were
// scoped (as CSS) by postcss-modules' built-in FileSystemLoader —
// the virtual `*.module.css` rename must cover them too, or their
// composed tokens are silently dropped.
" composes: extlessClass from './extless';",
"}",
"",
].join("\n"),
// url() inside a composed dependency: the custom loader runs Vite's
// `preprocessCSS` (where the built-in FileSystemLoader ran only the
// scoping plugins). `preprocessCSS` has no plugin context to resolve or
// emit assets, so — exactly like the built-in loader (verified against
// a vanilla Vite build) — the url() must pass through verbatim, never
// mangled into a `__VITE_ASSET__` placeholder or dropped. Pin that
// parity here so a pipeline change surfaces as a test failure.
"styles/other.module.css":
".className {\n color: green;\n background-image: url('./dot.svg');\n}\n",
"styles/dot.svg":
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect width="1" height="1" fill="green"/></svg>\n',
"styles/plain.css": ".plainClass {\n margin: 7px;\n}\n",
"styles/extless": ".extlessClass {\n letter-spacing: 7px;\n}\n",
});
try {
const { css, js } = await buildAndCollect(tmpDir);
expect(css).toMatch(/_className_[\w-]+/);
expect(css).toContain("color:green");
expect(css).toMatch(/_plainClass_[\w-]+/);
expect(css).toContain("margin:7px");
expect(css).toMatch(/_extlessClass_[\w-]+/);
expect(css).toContain("letter-spacing:7px");
// Parity: the url() reference survives verbatim (the built-in loader
// behaves identically — composed-dep CSS bypasses Vite's url rewriter,
// which only runs with a plugin context). It must not be rewritten,
// mangled into an unresolved placeholder, or dropped.
expect(css).toMatch(/url\((['"]?)\.\/dot\.svg\1\)/);
expect(css).not.toContain("__VITE_ASSET__");
expect(js).toMatch(/_subClass_[\w-]+ _className_[\w-]+/);
expect(js).toMatch(/_fromPlain_[\w-]+ _plainClass_[\w-]+/);
expect(js).toMatch(/_fromExtless_[\w-]+ _extlessClass_[\w-]+/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 60_000);
});