Files
cloudflare__vinext/tests/css-url-assets.test.ts
T
James Anderson 8df2344e77 fix(css): preserve Sass partial asset URLs (#2338)
* fix(css): preserve Sass partial asset URLs

* fix(css): preserve Sass use namespaces

* fix(css): match Sass namespace derivation
2026-06-30 20:31:49 +01:00

436 lines
17 KiB
TypeScript

/**
* CSS url() asset dependencies must be emitted as static files (Next.js
* `asset/resource` parity), and byte-identical sources must keep distinct
* output filenames instead of collapsing under Vite/Rolldown content dedupe.
*
* Ported from Next.js test/e2e/app-dir/scss/url-global/url-global.test.ts
* https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/scss/url-global/url-global.test.ts
*
* The upstream fixture is SCSS, but the parity contract lives one layer lower
* (once CSS reaches the bundler), so these exercise plain CSS against dedicated
* minimal committed fixtures:
*
* - tests/fixtures/css-url-assets-pages — Pages Router. styles/global.module.css
* references dark.svg and the byte-identical dark2.svg from one stylesheet
* (pages/index.tsx, the ported index page); split-{a,b}.module.css reference
* them from separate page chunks (pages/split-{a,b}.tsx).
* - tests/fixtures/css-url-assets-app — App Router. app/page.module.css is the
* counterpart referenced from a client-component page (app/page.tsx).
*
* Both svg sources have identical bytes, which is what makes the dedupe
* collapse observable.
*/
import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test";
import { build, createBuilder } from "vite";
import type { Server } from "node:http";
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import vinext from "../packages/vinext/src/index.js";
import { restoreDedupedCssAssetReferences } from "../packages/vinext/src/build/css-url-assets.js";
import { createIsolatedFixture } from "./helpers.js";
// Dedicated, minimal committed fixtures (not the large app-basic/pages-basic):
// these build in ~1-2s, so the suite stays fast and these tests don't contend
// for CPU with the heavyweight fixture builds running in parallel under CI.
const PAGES_CSS_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/css-url-assets-pages");
const APP_CSS_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/css-url-assets-app");
const CLOUDFLARE_FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/cf-app-basic");
const CLOUDFLARE_PLUGIN_PATH = path.join(
CLOUDFLARE_FIXTURE_DIR,
"node_modules/@cloudflare/vite-plugin/dist/index.mjs",
);
const WORKER_ENTRY_PATH = path
.resolve(import.meta.dirname, "../packages/vinext/src/server/app-router-entry.ts")
.replaceAll("\\", "/");
type CloudflarePluginFactory = (options?: {
viteEnvironment?: { name: string; childEnvironments?: string[] };
}) => import("vite").Plugin;
const MEDIA_SVG_RE = (name: string) =>
new RegExp(`^/_next/static/media/${name}\\.[A-Za-z0-9_-]+\\.svg$`);
async function listFiles(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...(await listFiles(entryPath)));
else if (entry.isFile()) files.push(entryPath);
}
return files;
}
function extractStylesheetHrefs(html: string): string[] {
const hrefs: string[] = [];
const hrefRe = /<link\s+rel="stylesheet"[^>]*\shref="([^"]+\.css)"/g;
for (const match of html.matchAll(hrefRe)) {
if (match[1]) hrefs.push(match[1]);
}
return hrefs;
}
function extractCssUrls(css: string): string[] {
const urls: string[] = [];
const urlRe = /url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/g;
for (const match of css.matchAll(urlRe)) {
const url = match[1] ?? match[2] ?? match[3];
if (url) urls.push(url.trim());
}
return urls;
}
function svgUrls(css: string): string[] {
return extractCssUrls(css).filter((url) => url.includes(".svg"));
}
function extractRedTextClassNames(code: string): string[] {
return [...new Set(code.match(/_redText_[A-Za-z0-9_-]+_\d+/g) ?? [])];
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
// ── Unit: plugin phase metadata + inlined-chunk marker stripping ─────────────
function isPluginNamed(plugin: unknown, name: string): plugin is { name: string; apply?: unknown } {
return (
typeof plugin === "object" &&
plugin !== null &&
!Array.isArray(plugin) &&
"name" in plugin &&
plugin.name === name
);
}
describe("CSS url() asset plugins", () => {
it("marks and restores CSS URL assets only during build", () => {
const plugins = vinext({ disableAppRouter: true });
const markPlugin = plugins.find((plugin) =>
isPluginNamed(plugin, "vinext:css-url-assets-mark"),
);
const restorePlugin = plugins.find((plugin) =>
isPluginNamed(plugin, "vinext:css-url-assets-restore"),
);
expect(markPlugin).toMatchObject({ apply: "build" });
expect(restorePlugin).toMatchObject({ apply: "build" });
});
});
function makeChunkBundle(code: string) {
return {
"main.js": { type: "chunk" as const, fileName: "main.js", code },
} as unknown as Parameters<typeof restoreDedupedCssAssetReferences>[0];
}
function stripChunk(code: string): string {
const bundle = makeChunkBundle(code);
restoreDedupedCssAssetReferences(bundle, () => {});
return (bundle["main.js"] as { code: string }).code;
}
describe("restoreDedupedCssAssetReferences chunk handling", () => {
it("strips the marker from CSS inlined into a JS chunk (Vite's escaped-quote layout)", () => {
// `cssCodeSplit: false` / `?inline`: Vite JSON-stringifies the CSS into a JS
// string, so a quoted `url("...")` becomes `url(\"...\")` with escaped
// double quotes. The strip must be agnostic to that escaping.
const css =
'.x{background:url("/_next/static/media/dark.abc123.svg?vinext_css_url_asset=dark.svg")}';
const code = `const css = ${JSON.stringify(css)};`;
expect(code).toContain('\\"'); // sanity: realistic escaped-quote layout
const bundle = makeChunkBundle(code);
const emitted: unknown[] = [];
restoreDedupedCssAssetReferences(bundle, (asset) => emitted.push(asset));
const out = (bundle["main.js"] as { code: string }).code;
expect(out).not.toContain("vinext_css_url_asset");
expect(out).toContain("/_next/static/media/dark.abc123.svg");
// The escaped quotes and surrounding URL are preserved.
expect(out).toContain('url(\\"/_next/static/media/dark.abc123.svg\\")');
// Inlined CSS URLs are already final; no sibling media files are emitted.
expect(emitted).toHaveLength(0);
});
it("strips the marker from unquoted and single-quoted inlined url()", () => {
expect(stripChunk("url(/m/dark.svg?vinext_css_url_asset=dark.svg)")).toBe("url(/m/dark.svg)");
expect(stripChunk("url('/m/dark.svg?vinext_css_url_asset=dark.svg')")).toBe(
"url('/m/dark.svg')",
);
});
it("preserves other query params and fragments when stripping the marker", () => {
expect(stripChunk("url(/m/dark.svg?vinext_css_url_asset=dark.svg&v=2)")).toBe(
"url(/m/dark.svg?v=2)",
);
expect(stripChunk("url(/m/dark.svg?v=2&vinext_css_url_asset=dark.svg)")).toBe(
"url(/m/dark.svg?v=2)",
);
expect(stripChunk("url(/m/dark.svg?v=2&vinext_css_url_asset=dark.svg&w=3)")).toBe(
"url(/m/dark.svg?v=2&w=3)",
);
expect(stripChunk("url(/m/dark.svg?vinext_css_url_asset=dark.svg#f)")).toBe(
"url(/m/dark.svg#f)",
);
});
it("leaves chunks without the marker untouched", () => {
const original = 'const css = ".x{background:url(/_next/static/media/dark.abc123.svg)}";';
expect(stripChunk(original)).toBe(original);
});
});
// ── Integration (Pages Router): build + serve the committed pages-basic ──────
describe("Pages Router CSS url() asset emission", () => {
let tmpDir: string;
let server: Server;
let baseUrl: string;
beforeAll(async () => {
tmpDir = await createIsolatedFixture(PAGES_CSS_FIXTURE, "vinext-css-url-pages-");
const outDir = path.join(tmpDir, "dist");
// Pages Router only — no RSC pipeline, so separate build() calls work.
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");
({ server } = await startProdServer({
port: 0,
host: "127.0.0.1",
outDir,
noCompression: true,
}));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
baseUrl = `http://127.0.0.1:${port}`;
}, 180_000);
afterAll(async () => {
if (server) await closeServer(server);
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function fetchPageStylesheets(route: string): Promise<string> {
const pageRes = await fetch(`${baseUrl}${route}`);
expect(pageRes.status, `page ${route} should render`).toBe(200);
const html = await pageRes.text();
const hrefs = extractStylesheetHrefs(html);
expect(hrefs.length, `expected a stylesheet link for ${route}:\n${html}`).toBeGreaterThan(0);
const cssTexts: string[] = [];
for (const href of hrefs) {
const cssRes = await fetch(new URL(href, baseUrl));
expect(cssRes.status, `stylesheet ${href} should be served`).toBe(200);
cssTexts.push(await cssRes.text());
}
return cssTexts.join("\n");
}
async function expectServedSvg(assetUrl: string): Promise<void> {
const res = await fetch(new URL(assetUrl, baseUrl));
expect(res.status, `expected ${assetUrl} to be served`).toBe(200);
expect(res.headers.get("content-type")).toMatch(/^image\/svg\+xml/);
expect(await res.text()).toContain("<svg");
}
it("emits both byte-identical url() svgs from one stylesheet (url-global parity)", async () => {
const css = await fetchPageStylesheets("/");
expect(css).toContain("redText");
const assetUrls = svgUrls(css);
expect(assetUrls).toEqual([
expect.stringMatching(MEDIA_SVG_RE("dark")),
expect.stringMatching(MEDIA_SVG_RE("dark2")),
]);
expect(new Set(assetUrls).size, "asset URLs should be unique").toBe(assetUrls.length);
for (const assetUrl of assetUrls) await expectServedSvg(assetUrl);
});
it("preserves url() asset provenance across separate page CSS chunks", async () => {
const cssA = await fetchPageStylesheets("/split-a");
const cssB = await fetchPageStylesheets("/split-b");
const aSvgs = svgUrls(cssA);
const bSvgs = svgUrls(cssB);
expect(aSvgs).toEqual([expect.stringMatching(MEDIA_SVG_RE("dark"))]);
expect(bSvgs).toEqual([expect.stringMatching(MEDIA_SVG_RE("dark2"))]);
expect(new Set([...aSvgs, ...bSvgs]).size, "split chunk URLs should be unique").toBe(
aSvgs.length + bSvgs.length,
);
for (const assetUrl of [...aSvgs, ...bSvgs]) await expectServedSvg(assetUrl);
});
it("preserves asset basenames from imported Sass partials", async () => {
const css = await fetchPageStylesheets("/partial-url");
const assetUrls = svgUrls(css).filter((url) => url.includes("partial-dark"));
expect(assetUrls).toEqual([
expect.stringMatching(MEDIA_SVG_RE("partial-dark")),
expect.stringMatching(MEDIA_SVG_RE("partial-dark2")),
]);
expect(new Set(assetUrls).size).toBe(2);
for (const assetUrl of assetUrls) await expectServedSvg(assetUrl);
});
});
// ── Integration (App Router): inspect plain and Cloudflare build output ──────
describe.each(["plain", "cloudflare"] as const)(
"App Router CSS url() asset emission (%s build)",
(buildTarget) => {
let tmpDir: string;
let clientDir: string;
let serverDir: string;
beforeAll(async () => {
// Build an isolated copy so the client output (which vinext writes to
// <root>/dist/client) lands in a temp dir instead of the committed fixture.
// Borrow app-basic's node_modules for the RSC/React-server deps the App
// Router build needs (@vitejs/plugin-rsc, react-server-dom-webpack, …).
tmpDir = await createIsolatedFixture(
APP_CSS_FIXTURE,
"vinext-css-url-app-",
undefined,
path.join(CLOUDFLARE_FIXTURE_DIR, "node_modules"),
);
const plugins: import("vite").PluginOption[] = [vinext({ appDir: tmpDir })];
if (buildTarget === "cloudflare") {
await fs.mkdir(path.join(tmpDir, "worker"), { recursive: true });
await fs.writeFile(
path.join(tmpDir, "wrangler.jsonc"),
`{
"name": "vinext-css-url-assets",
"compatibility_date": "2026-02-12",
"compatibility_flags": ["nodejs_compat"],
"main": "./worker/index.ts",
"assets": { "not_found_handling": "none", "binding": "ASSETS" }
}\n`,
);
await fs.writeFile(
path.join(tmpDir, "worker/index.ts"),
`import handler from ${JSON.stringify(WORKER_ENTRY_PATH)};\nexport default handler;\n`,
);
const { cloudflare } = (await import(pathToFileURL(CLOUDFLARE_PLUGIN_PATH).href)) as {
cloudflare: CloudflarePluginFactory;
};
plugins.push(cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }));
}
const builder = await createBuilder({
root: tmpDir,
configFile: false,
plugins,
logLevel: "silent",
build: { assetsInlineLimit: 0 },
});
await builder.buildApp();
clientDir = path.join(tmpDir, "dist", "client");
serverDir = path.join(tmpDir, "dist", "server");
}, 180_000);
afterAll(async () => {
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
it("emits both byte-identical page CSS module url() svgs under /_next/static/media/", async () => {
const cssFiles = (await listFiles(clientDir)).filter((file) => file.endsWith(".css"));
const cssTexts = await Promise.all(cssFiles.map((file) => fs.readFile(file, "utf-8")));
const routeCss = cssTexts.find((text) => text.includes("redText"));
expect(
routeCss,
"expected emitted client CSS containing the url-assets route class",
).toBeDefined();
const assetUrls = svgUrls(routeCss ?? "");
expect(assetUrls).toEqual([
expect.stringMatching(MEDIA_SVG_RE("dark")),
expect.stringMatching(MEDIA_SVG_RE("dark2")),
]);
expect(new Set(assetUrls).size, "App Router asset URLs should be unique").toBe(
assetUrls.length,
);
// Marker must not leak into emitted CSS.
expect(routeCss).not.toContain("vinext_css_url_asset");
// Each referenced media file must exist on disk in the client output.
for (const assetUrl of assetUrls) {
const stat = await fs.stat(path.join(clientDir, assetUrl));
expect(stat.isFile(), `expected emitted asset ${assetUrl}`).toBe(true);
}
});
it("uses the same CSS Module class name in server, client JS, and client CSS", async () => {
const serverFiles = (await listFiles(serverDir)).filter((file) => file.endsWith(".js"));
const serverCssFiles = (await listFiles(serverDir)).filter((file) => file.endsWith(".css"));
const clientFiles = (await listFiles(clientDir)).filter((file) => file.endsWith(".js"));
const cssFiles = (await listFiles(clientDir)).filter((file) => file.endsWith(".css"));
const serverCode = (
await Promise.all(serverFiles.map((file) => fs.readFile(file, "utf-8")))
).join("\n");
const serverCss = (
await Promise.all(serverCssFiles.map((file) => fs.readFile(file, "utf-8")))
).join("\n");
const clientCode = (
await Promise.all(clientFiles.map((file) => fs.readFile(file, "utf-8")))
).join("\n");
const clientCss = (
await Promise.all(cssFiles.map((file) => fs.readFile(file, "utf-8")))
).join("\n");
const serverClassNames = extractRedTextClassNames(serverCode);
const clientClassNames = extractRedTextClassNames(clientCode);
const cssClassNames = extractRedTextClassNames(clientCss);
expect(serverClassNames).toHaveLength(1);
expect(clientClassNames).toEqual(serverClassNames);
expect(cssClassNames).toEqual(serverClassNames);
expect(serverCode).not.toContain("vinext_css_url_asset");
expect(serverCss).not.toContain("vinext_css_url_asset");
expect(clientCode).not.toContain("vinext_css_url_asset");
expect(clientCss).not.toContain("vinext_css_url_asset");
const serverAssetUrls = svgUrls(serverCss);
const clientAssetUrls = svgUrls(clientCss);
expect(serverAssetUrls).toEqual(clientAssetUrls);
for (const assetUrl of serverAssetUrls) {
const stat = await fs.stat(path.join(clientDir, assetUrl));
expect(stat.isFile(), `expected SSR CSS asset ${assetUrl} in client output`).toBe(true);
}
});
},
);