mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
efa18a3f80
* fix(pages-router): include _app module assets in served page HTML When a Pages Router page renders, vinext's prod server collects asset tags only for the matched page module. _app.tsx — which wraps every page — is omitted, so any CSS/JS it imports is built and emitted but never <link>ed from the rendered HTML. For SCSS users this is the LHF-5 cluster from the Next.js deploy suite: when global.scss is imported via _app.tsx, Vite compiles it correctly and emits assets/_app-*.css, but the browser never sees a <link rel="stylesheet"> for it. The page renders with the user-agent default colour (rgb(0, 0, 0)) instead of the SCSS-defined colour. Fix: embed the resolved pages/_app file path at build time and include it in the moduleIds passed to collectAssetTags, so the SSR manifest's CSS/JS entries for _app are emitted alongside the page's own. Includes a regression test (tests/scss.test.ts) plus a fixture page that imports a .scss file with a variable. The test covers App Router dev, App Router production build, Pages Router dev, and the Pages Router production server end-to-end (build → start prod server → fetch page → assert linked stylesheet has the resolved colour). Skipped automatically when sass is not installed — vinext relies on Vite's built-in SCSS preprocessor, which requires the user to install sass (or sass-embedded) themselves. Matches Vite's model. * fix: isolate SCSS fixture and silence TS check for optional sass import The first revision of this PR added an SCSS fixture under tests/fixtures/app-basic/app/nextjs-compat/scss-test/. That directory is scanned by every test that boots the shared app-basic fixture, so when sass is not installed (CI default) every page in app-basic fails the route-graph scan and 500s. jsx-in-js, app-css, node-modules-css, and ~dozens of integration tests went red as a result (CI run https://github.com/cloudflare/vinext/actions/runs/25913568939). The TypeScript check job also failed because `await import("sass")` produces TS2307 when sass isn't declared as a workspace dep. Fixes: - Drop the shared fixture under app-basic. tests/scss.test.ts now materialises its Pages Router SCSS fixture in a per-test tmpdir (same pattern as the existing Pages Router scenario), so the shared fixture's route-graph scan never sees a .scss import. No other test is affected. - The App Router test scenarios from the first revision are removed. They required additional node_modules wiring for @vitejs/plugin-rsc to resolve from a tmpdir and weren't needed to validate the fix — the actual change is in the Pages Router asset-collection path, and the Pages Router tests exercise it end-to-end (dev + production build + linked stylesheet assertion). - Replace @ts-expect-error with @ts-ignore on the dynamic sass import. sass may or may not be installed depending on the environment; when it is, there's no type error to expect and @ts-expect-error itself errors as unused. @ts-ignore is the right tool for an optional peer. The pages-server-entry.ts fix from the first revision is unchanged — this commit only re-shapes the test surface so it doesn't poison the shared fixture.
219 lines
8.5 KiB
TypeScript
219 lines
8.5 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.
|
|
*
|
|
* Uses a per-test tmpdir fixture rather than adding files to a shared
|
|
* `tests/fixtures/*` tree. A shared SCSS fixture would break every test
|
|
* that boots that fixture when `sass` is not installed (CI default),
|
|
* because the route-graph scan trips on the unresolved `.scss` import
|
|
* before any test starts.
|
|
*
|
|
* 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";
|
|
|
|
// Skip the suite when `sass` is not installed. SCSS preprocessing is a
|
|
// peer dependency contract: vinext relies on Vite's built-in handling
|
|
// which requires the user to install `sass` (or `sass-embedded`).
|
|
// `@ts-ignore` (not `@ts-expect-error`) because `sass` may or may not be
|
|
// installed depending on the dev environment — when it is, the import
|
|
// resolves and there is no type error to expect.
|
|
let sassAvailable = false;
|
|
try {
|
|
// @ts-ignore Optional peer dependency, not declared in this repo
|
|
await import("sass");
|
|
sassAvailable = true;
|
|
} catch {
|
|
sassAvailable = false;
|
|
}
|
|
|
|
const describeIfSass = sassAvailable ? describe : describe.skip;
|
|
|
|
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 (if installed) `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;
|
|
}
|
|
|
|
describeIfSass("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);
|
|
});
|