Files
cloudflare__vinext/tests/tsconfig-path-alias-build.test.ts
T
Divanshu Chauhan (divkix) fef54db9b4 fix: skip MDX files during RSC scan-build to prevent parse errors (#668)
* fix: skip MDX files during RSC scan-build to prevent parse errors (issue #659)

Adds a vinext:rsc-scan-mdx-guard plugin that runs with enforce:"pre"
to ensure MDX files are skipped before rsc:scan-strip processes them.
This prevents es-module-lexer parse errors when MDX files contain
frontmatter (---) or JSX that isn't valid JavaScript syntax.

Also adds a regression test that uses MDX files with YAML frontmatter
loaded via import.meta.glob to verify the build succeeds.

* fix(mdx): return stub module for undetected MDX files during scan-build

The guard plugin was a no-op that passed raw MDX through unchanged. When
rsc:scan-strip received this raw MDX, es-module-lexer.parse() failed on
frontmatter (---) and JSX syntax.

The real fix is in vinext:mdx: when mdxDelegate is null (no MDX files in app/
or pages/), return a valid JS stub module instead of undefined. This handles
MDX files that enter the build graph via import.meta.glob outside app/pages.

- Remove vinext:rsc-scan-mdx-guard plugin (was a no-op)
- Modify vinext:mdx to return stub module when mdxDelegate is null
- Remove app/mdx-probe/page.mdx from test (was hiding the bug)
- Replace fragile '---' assertion with specific frontmatter checks

Fixes issue #659

* fix(mdx): lazily compile globbed mdx imports

Compile plain .mdx files on demand when they enter the graph via import.meta.glob outside app/pages so scan-build stops crashing without discarding runtime content. Restore the existing happy-path MDX fixture, add focused lazy-compilation coverage, and extract shared Cloudflare fixture setup to keep the regression tests honest.

* fix review issues

* fix: use absolute path for root tsconfig alias to fix import.meta.glob resolution

toViteAliasReplacement was returning "/" (Vite root-relative convention) when
the tsconfig path alias pointed to the project root (e.g. "@/*": ["./*"]).
This worked for regular imports but broke import.meta.glob because Vite's
glob plugin resolves patterns via this.resolve(), which couldn't properly
resolve the "/" replacement to matching filesystem files.

Changed to return the absolute path instead, which works correctly for both
regular imports and glob pattern resolution.

Signed-off-by: Divanshu Chauhan <divkix@divkix.me>
Made-with: Cursor

* refactor: address review feedback on MDX lazy initialization

- Add clarifying comments for mdxDelegatePromise caching semantics
- Document subtle control flow when hasUserMdxPlugin is true
- Suppress duplicate warning on on-demand MDX init (only warn during detection)
- Add case-insensitive extension check in transform hook
- Add test for query parameter edge cases (?raw, ?v=123, etc.)

* refactor: address review feedback on MDX handling

- Merge duplicate query-param MDX skip tests in pages-router.test.ts
- Tighten hasUserMdxPlugin control flow comment for clarity
- Make scanDirForMdx case-insensitive to align with transform check

Refs: PR #668 review feedback

* docs: clarify mdx delegate fallback comment

* chore: trigger ci

---------

Signed-off-by: Divanshu Chauhan <divkix@divkix.me>
2026-03-29 19:51:50 +01:00

316 lines
7.9 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createBuilder } from "vite";
import { afterEach, describe, expect, it } from "vite-plus/test";
import vinext from "../packages/vinext/src/index.js";
const tmpDirs: string[] = [];
const workerEntryPath = path
.resolve(import.meta.dirname, "../packages/vinext/src/server/app-router-entry.ts")
.replace(/\\/g, "/");
const cfPluginPath = path.resolve(
import.meta.dirname,
"./fixtures/cf-app-basic/node_modules/@cloudflare/vite-plugin/dist/index.mjs",
);
type CloudflarePluginFactory = (opts?: {
viteEnvironment?: { name: string; childEnvironments?: string[] };
}) => import("vite").Plugin;
function writeFixtureFile(root: string, filePath: string, content: string) {
const absPath = path.join(root, filePath);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content);
}
function readTextFilesRecursive(root: string): string {
let output = "";
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
output += readTextFilesRecursive(entryPath);
continue;
}
if (!entry.name.endsWith(".js")) continue;
output += fs.readFileSync(entryPath, "utf-8");
}
return output;
}
async function loadCloudflarePlugin(): Promise<CloudflarePluginFactory> {
const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as {
cloudflare: CloudflarePluginFactory;
};
return cloudflare;
}
function writeCloudflareAppFixture(root: string, name: string) {
fs.symlinkSync(
path.resolve(import.meta.dirname, "../node_modules"),
path.join(root, "node_modules"),
"junction",
);
writeFixtureFile(
root,
"package.json",
JSON.stringify(
{
name,
private: true,
type: "module",
},
null,
2,
),
);
writeFixtureFile(
root,
"wrangler.jsonc",
`{
"name": ${JSON.stringify(name)},
"compatibility_date": "2026-02-12",
"compatibility_flags": ["nodejs_compat"],
"main": "./worker/index.ts",
"assets": {
"not_found_handling": "none",
"binding": "ASSETS"
}
}
`,
);
writeFixtureFile(
root,
"tsconfig.json",
JSON.stringify(
{
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "bundler",
jsx: "react-jsx",
strict: true,
skipLibCheck: true,
types: ["vite/client", "@vitejs/plugin-rsc/types"],
paths: {
"@/*": ["./*"],
},
},
include: ["app", "lib", "content", "*.ts", "*.tsx"],
},
null,
2,
),
);
writeFixtureFile(
root,
"app/layout.tsx",
`export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
`,
);
writeFixtureFile(
root,
"mdx-components.tsx",
`export function useMDXComponents(components: Record<string, unknown>) {
return components;
}
`,
);
writeFixtureFile(
root,
"worker/index.ts",
`import handler from ${JSON.stringify(workerEntryPath)};
export default handler;
`,
);
}
async function buildCloudflareAppFixture(root: string) {
const cloudflare = await loadCloudflarePlugin();
const builder = await createBuilder({
root,
configFile: false,
plugins: [
vinext({ appDir: root }),
cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }),
],
logLevel: "silent",
});
await builder.buildApp();
}
describe("App Router tsconfig path aliases in production builds", () => {
afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("transforms alias-based import.meta.glob and alias-based dynamic import in Cloudflare builds", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-tsconfig-alias-build-"));
tmpDirs.push(root);
writeCloudflareAppFixture(root, "vinext-tsconfig-alias-build");
writeFixtureFile(
root,
"app/page.tsx",
`import { getGlobPostCount } from "../lib/mdx-loader";
export default function HomePage() {
return <main>home {getGlobPostCount()}</main>;
}
`,
);
writeFixtureFile(
root,
"app/mdx-probe/page.mdx",
`# Probe
This file exists only to trigger vinext's MDX auto-detection in the fixture.
`,
);
writeFixtureFile(
root,
"lib/mdx-loader.ts",
`type MdxModule = {
default: React.ComponentType;
};
export const mdxModules = import.meta.glob("@/content/posts/**/*.mdx", {
eager: true,
}) as Record<string, MdxModule>;
export function getGlobPostCount(): number {
return Object.keys(mdxModules).length;
}
`,
);
writeFixtureFile(
root,
"lib/mdx-dynamic.ts",
`export async function loadDynamicPost(year: string, month: string, day: string, slug: string) {
return await import(\`@/content/posts/\${year}/\${month}/\${day}/\${slug}/index.mdx\`);
}
`,
);
writeFixtureFile(
root,
"app/dynamic-posts/[year]/[month]/[day]/[slug]/page.tsx",
`import { notFound } from "next/navigation";
import { loadDynamicPost } from "../../../../../../lib/mdx-dynamic";
export default async function DynamicPostPage({
params,
}: {
params: Promise<{ year: string; month: string; day: string; slug: string }>;
}) {
const { year, month, day, slug } = await params;
const mod = await loadDynamicPost(year, month, day, slug).catch(() => null);
if (!mod) {
notFound();
}
const Content = mod.default;
return (
<main data-testid="dynamic-post-page">
<h1>Dynamic Post</h1>
<Content />
</main>
);
}
`,
);
writeFixtureFile(
root,
"content/posts/2024/01/02/glob-post/index.mdx",
`# Globbed-Only MDX Post
This content came from an alias-based MDX import.
`,
);
writeFixtureFile(
root,
"content/posts/2024/01/02/dynamic-post/index.mdx",
`# Dynamic MDX Post
This content came from an alias-based dynamic MDX import.
`,
);
await buildCloudflareAppFixture(root);
const buildOutput = readTextFilesRecursive(path.join(root, "dist"));
expect(buildOutput).not.toContain('import.meta.glob("@/content/posts/**/*.mdx"');
expect(buildOutput).not.toContain("@/content/posts/");
}, 60_000);
it("import.meta.glob with MDX files containing frontmatter does not cause parse errors (issue #659)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-mdx-frontmatter-build-"));
tmpDirs.push(root);
writeCloudflareAppFixture(root, "vinext-mdx-frontmatter-test");
writeFixtureFile(
root,
"app/page.tsx",
`import { getGlobPostCount } from "../lib/mdx-loader";
export default function HomePage() {
return <main>home {getGlobPostCount()}</main>;
}
`,
);
writeFixtureFile(
root,
"lib/mdx-loader.ts",
`type MdxModule = {
default: React.ComponentType;
frontmatter?: {
title: string;
date: string;
};
};
export const mdxModules = import.meta.glob("@/content/posts/**/*.mdx", {
eager: true,
}) as Record<string, MdxModule>;
export function getGlobPostCount(): number {
return Object.keys(mdxModules).length;
}
`,
);
writeFixtureFile(
root,
"content/posts/2025/08/20/second-post/index.mdx",
`---
title: "Second Post"
date: "2025-08-20"
---
<span className="text-red-500">This is a post with frontmatter and JSX.</span>
`,
);
await buildCloudflareAppFixture(root);
const buildOutput = readTextFilesRecursive(path.join(root, "dist"));
expect(buildOutput).not.toContain('import.meta.glob("@/content/posts/**/*.mdx"');
expect(buildOutput).not.toContain("@/content/posts/");
expect(buildOutput).not.toContain('title: "Second Post"');
expect(buildOutput).toContain("text-red-500");
}, 60_000);
});