mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
f8481ac04d
* 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>
338 lines
13 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|