Files
cloudflare__vinext/tests/css-url-assets.test.ts
T
James Anderson f8481ac04d fix(css): preserve distinct media filenames for CSS url() assets (#1725)
* fix(css): preserve distinct media filenames for CSS url() assets

Match Next.js `asset/resource` behaviour for CSS `url()` dependencies:
emit them as files under `/_next/static/media/` instead of inlining, and
keep byte-identical sources (e.g. `dark.svg` / `dark2.svg`) as separate
output files instead of letting Vite/Rolldown's content-based asset
dedupe collapse them onto one filename.

There is no config switch to disable that dedupe (vitejs/vite#8632). The
native escape hatch is `this.emitFile({ fileName })`, which is never
deduped. A build-only, client-scoped pair of plugins:

  - mark: during the client CSS transform, tag each relative asset
    `url()` with a private `?vinext_css_url_asset=<source-basename>`
    query — the only durable carrier of per-reference provenance, since
    once Rolldown dedupes, the bundle can't tell which reference came
    from which source.
  - restore: at `generateBundle`, read the marker back, emit a sibling
    file under the wanted source name via the `fileName` escape hatch,
    rewrite the reference, and strip the marker.

Each reference resolves from its own marker, so split CSS chunks stay
correct without a shared cursor or bundle-iteration-order dependence.

This is a slimmer reimplementation of the approach explored in #1703:
same mark/restore mechanism and public surface, but the restore side
drops the AssetIndexes machinery, filename-reservation loop, and
multi-fallback lookups in favour of a single basename map + emitted-set,
since the sibling-filename construction (source-stem + content-hash +
ext) makes those guarded cases unreachable.

Tests port the Next.js scss/url-global e2e into the committed
`pages-basic` and `app-basic` fixtures (built in isolation), covering
the single-stylesheet two-identical-urls case and cross-chunk provenance.

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>

* fix(css): merge duplicate vite import; drop ReDoS-flagged regex; clarify comments

- tests/css-url-assets.test.ts: merge the two `vite` imports into one
  (import/no-duplicates lint error that failed CI Check).
- client-build-config.ts: replace `/\/+$/` trailing-slash strip with a
  linear loop so CodeQL stops flagging a (false-positive) ReDoS.
- index.ts: reword the css-url mark transform's `map: null` comment — the
  marker shifts columns transiently but is stripped before final output.
- css-url-assets.ts: note the basename-uniqueness assumption in assetsByBase.

Addresses review feedback on #1725.

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>

* test(css): use dedicated minimal fixtures for url() asset tests

The url() asset tests previously added routes to the shared app-basic /
pages-basic fixtures and built those whole apps in isolation. That made
the tests CPU-heavy and, run concurrently under CI, contended with
app-router.test.ts's static-export test enough to push it past its 30s
timeout (it passes comfortably locally and on main; the extra parallel
load was the trigger). The added app-basic route also slightly inflated
every app-basic-wide test (static export renders all routes).

Move the fixtures into dedicated minimal committed apps
(tests/fixtures/css-url-assets-{app,pages}) that build in ~1-2s. Same
observable contract (byte-identical dark.svg/dark2.svg referenced from
one stylesheet, plus the split-chunk provenance case), now isolated from
the heavyweight shared fixtures and fast enough not to contend.

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>

* docs(test): update css-url-assets header to reference the dedicated fixtures

The file header still described the pre-refactor layout (pages-basic /
app-basic paths that no longer exist). Point it at the dedicated
css-url-assets-{pages,app} fixtures. Comment-only.

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>

---------

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
2026-06-03 22:46:04 +01:00

338 lines
13 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 vinext from "../packages/vinext/src/index.js";
import { restoreDedupedCssAssetReferences } from "../packages/vinext/src/build/css-url-assets.js";
import { APP_FIXTURE_DIR, 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 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"));
}
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);
});
});
// ── Integration (App Router): build the committed app-basic, inspect output ──
describe("App Router CSS url() asset emission", () => {
let tmpDir: string;
let clientDir: 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(APP_FIXTURE_DIR, "node_modules"),
);
const builder = await createBuilder({
root: tmpDir,
configFile: false,
plugins: [vinext({ appDir: tmpDir })],
logLevel: "silent",
});
await builder.buildApp();
clientDir = path.join(tmpDir, "dist", "client");
}, 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);
}
});
});