mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
2bf68e906b
Vite+ is now in beta. https://voidzero.dev/posts/announcing-vite-plus-beta
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
/**
|
|
* Regression test for issue #1549 — production CSS ordering for
|
|
* `app/global-not-found.tsx`.
|
|
*
|
|
* Ported from Next.js:
|
|
* test/e2e/app-dir/initial-css-order/initial-css-order.test.ts
|
|
* (the `should serve styles in the correct order for global-not-found` case)
|
|
* https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/initial-css-order/initial-css-order.test.ts
|
|
*
|
|
* Why a *production* build test (not the dev-server SSR test in
|
|
* tests/nextjs-compat/global-not-found.test.ts):
|
|
*
|
|
* The bug only manifests in the production RSC build. There are two
|
|
* complementary leak vectors:
|
|
*
|
|
* 1. When the root layout and `app/global-not-found.tsx` import the same
|
|
* stylesheet, Vite/Rolldown can dedupe the shared import into the layout's
|
|
* CSS bundle. The fix isolates global-not-found side-effect stylesheet
|
|
* imports with a private query so the 404 document owns its shared CSS
|
|
* resource separately.
|
|
* 2. When global-not-found imports only its own distinct CSS, it can still
|
|
* inherit the root layout's CSS through the shared RSC/framework chunk. The
|
|
* `createRscFrameworkChunkOutputConfig` split keeps that shared chunk
|
|
* CSS-free.
|
|
*
|
|
* Fixture: tests/fixtures/global-not-found-css-order/
|
|
* - layout.tsx imports red.css then green.css -> green wins (matched routes)
|
|
* - global-not-found.tsx imports the same red.css plus its own gnf-a.css and
|
|
* gnf-b.css -> red must win on route-miss 404s, including the literal /404
|
|
* path, and global-not-found-only assets must not leak onto matched routes.
|
|
*
|
|
* Assertions mirror upstream: the 404 document must link ONLY
|
|
* global-not-found's stylesheet and must NOT carry the root layout's green
|
|
* stylesheet.
|
|
*
|
|
* The fixture also includes a lazy `react-dom/server.edge` import for issue
|
|
* #2073. Importing the production RSC entry must not eagerly evaluate React's
|
|
* throwing server-component stub.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { createBuilder, preview } from "vite";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
|
|
import vinext from "../packages/vinext/src/index.js";
|
|
|
|
const FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/global-not-found-css-order");
|
|
|
|
/**
|
|
* Extract the contents of every `<link rel="stylesheet">` href in document
|
|
* order. CSS cascade is order-sensitive, so order matters for the assertion.
|
|
*/
|
|
function extractCssLinks(html: string): string[] {
|
|
const hrefs: string[] = [];
|
|
const linkRe = /<link\b[^>]*\brel="stylesheet"[^>]*>/gi;
|
|
for (const m of html.matchAll(linkRe)) {
|
|
const hrefMatch = /\bhref="([^"]+)"/i.exec(m[0]);
|
|
if (hrefMatch) hrefs.push(hrefMatch[1]);
|
|
}
|
|
return hrefs;
|
|
}
|
|
|
|
/**
|
|
* Read the built CSS bundle for a given stylesheet href so we can assert on
|
|
* the *rule* that wins, independent of hashed filenames. Hrefs look like
|
|
* `/_next/static/<hash>.css`; map them onto the client output tree.
|
|
*/
|
|
function readCssAsset(clientDir: string, href: string): string {
|
|
const rel = href.replace(/^\//, "");
|
|
const full = path.join(clientDir, rel);
|
|
return fs.readFileSync(full, "utf-8");
|
|
}
|
|
|
|
async function buildFixture(fixtureDir: string): Promise<void> {
|
|
const builder = await createBuilder({
|
|
root: fixtureDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: fixtureDir })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
}
|
|
|
|
async function startFixturePreview(fixtureDir: string): Promise<{
|
|
server: Awaited<ReturnType<typeof preview>>;
|
|
baseUrl: string;
|
|
}> {
|
|
const server = await preview({
|
|
root: fixtureDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: fixtureDir })],
|
|
preview: { port: 0 },
|
|
logLevel: "silent",
|
|
});
|
|
const addr = server.httpServer.address();
|
|
const baseUrl = addr && typeof addr === "object" ? `http://localhost:${addr.port}` : "";
|
|
expect(baseUrl).not.toBe("");
|
|
return { server, baseUrl };
|
|
}
|
|
|
|
async function transformGlobalNotFoundMdx(root: string, code: string): Promise<string | null> {
|
|
const appDir = path.join(root, "app");
|
|
fs.mkdirSync(appDir, { recursive: true });
|
|
fs.writeFileSync(path.join(appDir, "global-not-found.mdx"), code);
|
|
fs.writeFileSync(
|
|
path.join(root, "next.config.ts"),
|
|
`export default {
|
|
pageExtensions: ["mdx", "tsx", "ts", "jsx", "js"],
|
|
experimental: {
|
|
globalNotFound: true,
|
|
},
|
|
};
|
|
`,
|
|
);
|
|
|
|
const plugins = vinext({ appDir: root }) as any[];
|
|
const configPlugin = plugins.find((p) => p.name === "vinext:config");
|
|
const isolationPlugin = plugins.find((p) => p.name === "vinext:global-not-found-css-isolation");
|
|
expect(configPlugin).toBeDefined();
|
|
expect(isolationPlugin).toBeDefined();
|
|
|
|
await configPlugin.config({ root, plugins: [] }, { command: "build", mode: "production" });
|
|
const result = await isolationPlugin.transform.handler.call(
|
|
isolationPlugin,
|
|
code,
|
|
path.join(appDir, "global-not-found.mdx"),
|
|
{},
|
|
);
|
|
if (!result) return null;
|
|
return typeof result === "string" ? result : result.code;
|
|
}
|
|
|
|
describe("App Router: global-not-found CSS order (production, #1549)", () => {
|
|
const distDir = path.resolve(FIXTURE_DIR, "dist");
|
|
const clientDir = path.join(distDir, "client");
|
|
let previewServer: Awaited<ReturnType<typeof preview>>;
|
|
let baseUrl: string;
|
|
let startupImportValidated = false;
|
|
|
|
beforeAll(async () => {
|
|
await buildFixture(FIXTURE_DIR);
|
|
}, 120_000);
|
|
|
|
async function startPreviewServer(): Promise<void> {
|
|
if (!startupImportValidated) {
|
|
throw new Error("The direct RSC entry import assertion must run before preview startup");
|
|
}
|
|
if (previewServer) return;
|
|
const started = await startFixturePreview(FIXTURE_DIR);
|
|
previewServer = started.server;
|
|
baseUrl = started.baseUrl;
|
|
}
|
|
|
|
afterAll(() => {
|
|
previewServer?.httpServer.close();
|
|
fs.rmSync(distDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("does not eagerly evaluate dynamically imported React server stubs", async () => {
|
|
const entryUrl = pathToFileURL(path.join(distDir, "server", "index.js"));
|
|
entryUrl.searchParams.set("test", String(Date.now()));
|
|
|
|
await expect(import(entryUrl.href)).resolves.toBeDefined();
|
|
startupImportValidated = true;
|
|
});
|
|
|
|
it("matched routes serve the root layout's CSS (green wins)", async () => {
|
|
await startPreviewServer();
|
|
const res = await fetch(`${baseUrl}/`);
|
|
expect(res.status).toBe(200);
|
|
const html = await res.text();
|
|
const links = extractCssLinks(html);
|
|
// The home page is wrapped by the root layout, so its CSS must be present.
|
|
expect(links.length).toBeGreaterThanOrEqual(1);
|
|
const css = links.map((h) => readCssAsset(clientDir, h)).join("\n");
|
|
expect(css).toContain("--shared-red-css");
|
|
expect(css).toContain("--layout-green-css");
|
|
expect(css).toContain("green");
|
|
expect(css).not.toContain("--global-not-found-a-css");
|
|
expect(css).not.toContain("--global-not-found-b-css");
|
|
});
|
|
|
|
it("route-miss 404 serves global-not-found's CSS with red winning, and no layout CSS leak", async () => {
|
|
await startPreviewServer();
|
|
for (const pathname of ["/does-not-exist", "/404"]) {
|
|
const res = await fetch(`${baseUrl}${pathname}`);
|
|
expect(res.status).toBe(404);
|
|
const html = await res.text();
|
|
// global-not-found.tsx ships its own document.
|
|
expect(html).toContain('data-global-not-found="true"');
|
|
|
|
const links = extractCssLinks(html);
|
|
expect(links.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const cssByHref = new Map(links.map((h) => [h, readCssAsset(clientDir, h)]));
|
|
const allCss = [...cssByHref.values()].join("\n");
|
|
|
|
// The literal upstream failure path is /404. The global-not-found module
|
|
// imports the same red.css as the root layout, so production builds must
|
|
// isolate that import instead of linking the layout bundle where green
|
|
// wins the cascade. It also imports gnf-a.css/gnf-b.css so this keeps
|
|
// coverage for the old framework-chunk leak: layout green must not arrive
|
|
// through a shared RSC/framework chunk either.
|
|
expect(allCss).toContain("--shared-red-css");
|
|
expect(allCss).toContain("--global-not-found-a-css");
|
|
expect(allCss).toContain("--global-not-found-b-css");
|
|
expect(allCss).toContain("background-color:red");
|
|
expect(allCss).not.toContain("--layout-green-css");
|
|
|
|
const isolatedSharedRedHref = [...cssByHref].find(
|
|
([, css]) => css.includes("--shared-red-css") && !css.includes("--layout-green-css"),
|
|
)?.[0];
|
|
expect(isolatedSharedRedHref).toBeDefined();
|
|
|
|
const matchedRes = await fetch(`${baseUrl}/`);
|
|
expect(matchedRes.status).toBe(200);
|
|
const matchedLinks = extractCssLinks(await matchedRes.text());
|
|
expect(matchedLinks).not.toContain(isolatedSharedRedHref);
|
|
}
|
|
});
|
|
|
|
it("isolates every consecutive semicolon-less MDX stylesheet import", async () => {
|
|
const tmpRoot = fs.realpathSync(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "vinext-gnf-mdx-isolation-")),
|
|
);
|
|
|
|
try {
|
|
const transformed = await transformGlobalNotFoundMdx(
|
|
tmpRoot,
|
|
`import "./red.css"
|
|
import "./gnf-a.css"
|
|
import "./gnf-b.css"
|
|
|
|
# global-not-found
|
|
`,
|
|
);
|
|
|
|
expect(transformed).toContain(`"./red.css?vinext-global-not-found-css"`);
|
|
expect(transformed).toContain(`"./gnf-a.css?vinext-global-not-found-css"`);
|
|
expect(transformed).toContain(`"./gnf-b.css?vinext-global-not-found-css"`);
|
|
|
|
const sameLine = await transformGlobalNotFoundMdx(
|
|
tmpRoot,
|
|
`import "./red.css"; import "./gnf-a.css";
|
|
|
|
# global-not-found
|
|
`,
|
|
);
|
|
|
|
expect(sameLine).toContain(`"./red.css?vinext-global-not-found-css"`);
|
|
expect(sameLine).toContain(`"./gnf-a.css?vinext-global-not-found-css"`);
|
|
} finally {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("matches custom global-not-found extensions without overlapping dot segments", () => {
|
|
const plugins = vinext({ appDir: FIXTURE_DIR }) as any[];
|
|
const isolationPlugin = plugins.find((p) => p.name === "vinext:global-not-found-css-isolation");
|
|
const idFilter = isolationPlugin.transform.filter.id as RegExp;
|
|
|
|
expect(idFilter.test("/app/global-not-found.tsx")).toBe(true);
|
|
expect(idFilter.test("/app/global-not-found.mdx")).toBe(true);
|
|
expect(idFilter.test("/app/global-not-found.page.tsx")).toBe(true);
|
|
expect(idFilter.test(String.raw`C:\app\global-not-found.platform.tsx?x=1`)).toBe(true);
|
|
expect(idFilter.test("/app/global-not-found")).toBe(false);
|
|
expect(idFilter.test("/app/other-global-not-found.tsx")).toBe(false);
|
|
expect(idFilter.test(`/app/global-not-found${".".repeat(40)}?x\n`)).toBe(false);
|
|
});
|
|
|
|
it("isolates CSS for global-not-found files discovered through custom pageExtensions", async () => {
|
|
const tmpRoot = fs.realpathSync(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "vinext-gnf-css-extension-")),
|
|
);
|
|
const tmpAppDir = path.join(tmpRoot, "app");
|
|
const tmpClientDir = path.join(tmpRoot, "dist", "client");
|
|
let tmpPreviewServer: Awaited<ReturnType<typeof preview>> | undefined;
|
|
|
|
try {
|
|
fs.cpSync(FIXTURE_DIR, tmpRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpAppDir, "global-not-found.page.tsx"),
|
|
`import "./red.css";
|
|
import "./gnf-a.css";
|
|
import "./gnf-b.css";
|
|
|
|
export default function GlobalNotFound() {
|
|
return (
|
|
<html data-global-not-found="true">
|
|
<body>
|
|
<h1 id="global-error-title">global-not-found</h1>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
fs.rmSync(path.join(tmpAppDir, "global-not-found.tsx"));
|
|
fs.rmSync(path.join(tmpRoot, "dist"), { recursive: true, force: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpRoot, "next.config.ts"),
|
|
`export default {
|
|
pageExtensions: ["page.tsx", "tsx", "ts", "jsx", "js"],
|
|
experimental: {
|
|
globalNotFound: true,
|
|
},
|
|
};
|
|
`,
|
|
);
|
|
|
|
await buildFixture(tmpRoot);
|
|
const started = await startFixturePreview(tmpRoot);
|
|
tmpPreviewServer = started.server;
|
|
|
|
const res = await fetch(`${started.baseUrl}/404`);
|
|
expect(res.status).toBe(404);
|
|
const html = await res.text();
|
|
expect(html).toContain('data-global-not-found="true"');
|
|
|
|
const links = extractCssLinks(html);
|
|
expect(links.length).toBeGreaterThanOrEqual(1);
|
|
const css = links.map((h) => readCssAsset(tmpClientDir, h)).join("\n");
|
|
expect(css).toContain("--shared-red-css");
|
|
expect(css).toContain("--global-not-found-b-css");
|
|
expect(css).toContain("background-color:red");
|
|
expect(css).not.toContain("--layout-green-css");
|
|
} finally {
|
|
tmpPreviewServer?.httpServer.close();
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
}, 120_000);
|
|
});
|