Files
cloudflare__vinext/tests/scss.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

201 lines
7.8 KiB
TypeScript

/**
* Tests for SCSS / Sass preprocessing in vinext (Pages Router).
*
* Mirrors Next.js's SCSS support: when a page imports a `.scss` file,
* the file is preprocessed (Sass variables resolved, partials inlined)
* before reaching the browser. The resolved CSS — not the raw SCSS —
* is what should be served, and crucially the served HTML must include
* a `<link rel="stylesheet">` so the browser actually loads it.
*
* Vite has built-in SCSS support when the user installs `sass` (or
* `sass-embedded`). vinext relies on that built-in handling; this test
* verifies vinext does not interfere with the pipeline, and that a
* stylesheet imported via `pages/_app.tsx` reaches the rendered HTML.
* `sass` is a root devDependency, so the suite always runs.
*
* Uses a per-test tmpdir fixture rather than adding files to a shared
* `tests/fixtures/*` tree, keeping the SCSS toolchain requirement out
* of fixtures shared with non-SCSS tests.
*
* Ported from Next.js: test/e2e/app-dir/scss/single-global/single-global.test.ts
* https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/scss/single-global/single-global.test.ts
*
* Relates to LHF-5 in the deploy-suite e2e review
* (https://github.com/cloudflare/vinext/actions/runs/25897889733).
*/
import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test";
import { build, createServer, type ViteDevServer } 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";
import { fetchHtml } from "./helpers.js";
const ROOT_NODE_MODULES = path.resolve(import.meta.dirname, "../node_modules");
// Regex for any CSS representation of rgb(0, 0, 255) — the SCSS variable
// value used across these tests. CSS minifiers in the build pipeline may
// emit any of these forms (rgb(), 6-digit hex, 3-digit hex, named colour).
const RESOLVED_BLUE_REGEX = /rgb\(\s*0\s*,\s*0\s*,\s*255\s*\)|#0000ff\b|#00f\b|\bblue\b/;
/**
* Materialize a minimal Pages Router fixture in a fresh tmpdir.
*
* Imports the SCSS file via `pages/_app.tsx` to match Next.js's
* `test/e2e/app-dir/scss/single-global/pages/_app.js` pattern. This
* exercises the exact code path that fails in the LHF-5 cluster:
* `_app`-imported CSS reaching the served HTML via `<link rel="stylesheet">`.
*
* Symlinks the workspace `node_modules` so the fixture can resolve
* `react`, `react-dom`, `vinext`, and `sass` without an extra
* install step.
*/
async function makePagesRouterScssFixture(): Promise<string> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-scss-pages-"));
await fs.symlink(ROOT_NODE_MODULES, path.join(tmpDir, "node_modules"), "junction");
const stylesDir = path.join(tmpDir, "styles");
await fs.mkdir(stylesDir, { recursive: true });
await fs.writeFile(
path.join(stylesDir, "global.scss"),
"$var: rgb(0, 0, 255);\n.scss-pages-text {\n color: $var;\n}\n",
);
const pagesDir = path.join(tmpDir, "pages");
await fs.mkdir(pagesDir, { recursive: true });
await fs.writeFile(
path.join(pagesDir, "_app.tsx"),
'import "../styles/global.scss";\n' +
"export default function App({ Component, pageProps }: any) {\n" +
" return <Component {...pageProps} />;\n" +
"}\n",
);
await fs.writeFile(
path.join(pagesDir, "index.tsx"),
"export default function Home() {\n" +
' return <div className="scss-pages-text">SCSS Pages Test</div>;\n' +
"}\n",
);
return tmpDir;
}
describe("SCSS preprocessing (Pages Router)", () => {
let server: ViteDevServer;
let baseUrl: string;
let tmpDir: string;
beforeAll(async () => {
tmpDir = await makePagesRouterScssFixture();
server = await createServer({
root: tmpDir,
configFile: false,
plugins: [vinext({ appDir: tmpDir })],
optimizeDeps: { holdUntilCrawlEnd: true },
server: { port: 0, cors: false },
logLevel: "silent",
});
await server.listen();
const addr = server.httpServer?.address();
if (addr && typeof addr === "object") {
baseUrl = `http://localhost:${addr.port}`;
}
await fetch(`${baseUrl}/`).catch(() => {});
}, 60_000);
afterAll(async () => {
await server?.close();
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
it("preprocesses a Pages Router _app.tsx SCSS import in dev", async () => {
const { res, html } = await fetchHtml(baseUrl, "/");
expect(res.status).toBe(200);
expect(html).toContain("SCSS Pages Test");
// Pages Router dev does not server-render `<link>` tags for CSS — the
// browser loads CSS via the JS module graph when it executes `_app`.
// Ask Vite for the compiled CSS via `?direct` to confirm the SCSS
// variable resolved (preprocessor ran) rather than being inlined verbatim.
const scssDirectUrl = "/styles/global.scss?direct";
const cssRes = await fetch(new URL(scssDirectUrl, baseUrl));
expect(cssRes.status).toBe(200);
const css = await cssRes.text();
expect(css).not.toContain("$var");
expect(css.toLowerCase()).toMatch(RESOLVED_BLUE_REGEX);
});
it("links and serves resolved SCSS through the production Pages Router server", async () => {
// End-to-end production parity check. Mirrors what a Next.js
// SCSS deploy test does at runtime: build → start prod server →
// fetch page → assert the linked stylesheet has the resolved colour.
// A failure here is what produces `rgb(0, 0, 0)` in the deploy suite
// (the browser sees `color: $var` which is invalid CSS and falls back
// to the user-agent default).
const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-scss-pages-build-"));
try {
await build({
root: tmpDir,
configFile: false,
plugins: [vinext({ disableAppRouter: true })],
logLevel: "silent",
build: {
outDir: path.join(outDir, "server"),
ssr: "virtual:vinext-server-entry",
rollupOptions: { output: { entryFileNames: "entry.js" } },
},
});
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" },
},
});
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
const { server: prodServer, port } = await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
noCompression: true,
});
try {
const prodUrl = `http://127.0.0.1:${port}`;
const res = await fetch(`${prodUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("SCSS Pages Test");
// The page must reference the compiled stylesheet via a <link>.
// If the CSS file isn't linked, the browser never loads any
// styles for the SCSS-defined classes — the exact failure mode
// of LHF-5 (`rgb(0, 0, 0)` instead of the SCSS colour).
const linkMatch = html.match(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+\.css)"/);
expect(linkMatch, 'expected <link rel="stylesheet"> in the served HTML').not.toBeNull();
const cssRes = await fetch(new URL(linkMatch![1]!, prodUrl));
expect(cssRes.status).toBe(200);
const css = await cssRes.text();
expect(css).not.toContain("$var");
expect(css.toLowerCase()).toMatch(RESOLVED_BLUE_REGEX);
} finally {
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
}
} finally {
await fs.rm(outDir, { recursive: true, force: true }).catch(() => {});
}
}, 60_000);
});