From e38d65b057cc0960d85513a1d06aecae96747a04 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 22 Mar 2026 19:46:20 -0700 Subject: [PATCH 1/5] 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. --- packages/vinext/src/index.ts | 18 +++ tests/tsconfig-path-alias-build.test.ts | 165 ++++++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 8a25adaa..678ef728 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2601,6 +2601,24 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return fn.call(this, code, id, options); }, }, + // Guard plugin: during RSC scan-build, skip MDX files to prevent + // rsc:scan-strip from trying to parse MDX frontmatter/JSX as JavaScript. + // rsc:scan-strip runs on all files during scan-build, and if it encounters + // raw MDX (frontmatter ---, JSX ), es-module-lexer's parse() fails. + // This plugin runs with enforce:"pre" before rsc:scan-strip (which is + // enforce:"post") to ensure MDX files are handled first. Even though + // vinext:mdx also has enforce:"pre", it may not be invoked on every MDX + // file during scan-build, so this guard provides an extra layer. + { + name: "vinext:rsc-scan-mdx-guard", + enforce: "pre", + transform(code, id) { + const queryIndex = id.indexOf("?"); + const baseId = queryIndex === -1 ? id : id.slice(0, queryIndex); + if (!baseId.endsWith(".mdx")) return null; + return { code, map: null }; + }, + }, // Shim React canary/experimental APIs (ViewTransition, addTransitionType) // that exist in Next.js's bundled React canary but not in stable React 19. // Provides graceful no-op fallbacks so projects using these APIs degrade diff --git a/tests/tsconfig-path-alias-build.test.ts b/tests/tsconfig-path-alias-build.test.ts index 147a9650..10ac3bdd 100644 --- a/tests/tsconfig-path-alias-build.test.ts +++ b/tests/tsconfig-path-alias-build.test.ts @@ -235,4 +235,169 @@ export default handler; 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); + 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", + ); + const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as { + cloudflare: (opts?: { + viteEnvironment?: { name: string; childEnvironments?: string[] }; + }) => import("vite").Plugin; + }; + + fs.symlinkSync( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(root, "node_modules"), + "junction", + ); + + writeFixtureFile( + root, + "package.json", + JSON.stringify( + { + name: "vinext-mdx-frontmatter-test", + private: true, + type: "module", + }, + null, + 2, + ), + ); + writeFixtureFile( + root, + "wrangler.jsonc", + `{ + "name": "vinext-mdx-frontmatter-test", + "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 ( + + {children} + + ); +} +`, + ); + writeFixtureFile( + root, + "app/page.tsx", + `import { getGlobPostCount } from "../lib/mdx-loader"; + +export default function HomePage() { + return
home {getGlobPostCount()}
; +} +`, + ); + writeFixtureFile( + root, + "app/mdx-probe/page.mdx", + `# Probe + +This file exists only to trigger vinext's MDX auto-detection. +`, + ); + 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; + +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" +--- + +This is a post with frontmatter and JSX. +`, + ); + writeFixtureFile( + root, + "worker/index.ts", + `import handler from ${JSON.stringify(workerEntryPath)}; + +export default handler; +`, + ); + + const builder = await createBuilder({ + root, + configFile: false, + plugins: [ + vinext({ appDir: root }), + cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }), + ], + logLevel: "silent", + }); + await builder.buildApp(); + + 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("---"); + expect(buildOutput).not.toContain("text-red-500"); + }, 60_000); }); From af6f644aaa21e8ba67bcf91093ca6ad33703c834 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 23 Mar 2026 20:53:17 -0700 Subject: [PATCH 2/5] 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 --- packages/vinext/src/index.ts | 38 ++++++++++++------------- tests/tsconfig-path-alias-build.test.ts | 26 ++++++----------- 2 files changed, 27 insertions(+), 37 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 678ef728..0204c401 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2595,28 +2595,26 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Skip ?raw and other query imports — @mdx-js/rollup ignores the query // and would compile the file as MDX instead of returning raw text. if (id.includes("?")) return; - if (!mdxDelegate?.transform) return; - const hook = mdxDelegate.transform; - const fn = typeof hook === "function" ? hook : hook.handler; - return fn.call(this, code, id, options); - }, - }, - // Guard plugin: during RSC scan-build, skip MDX files to prevent - // rsc:scan-strip from trying to parse MDX frontmatter/JSX as JavaScript. - // rsc:scan-strip runs on all files during scan-build, and if it encounters - // raw MDX (frontmatter ---, JSX ), es-module-lexer's parse() fails. - // This plugin runs with enforce:"pre" before rsc:scan-strip (which is - // enforce:"post") to ensure MDX files are handled first. Even though - // vinext:mdx also has enforce:"pre", it may not be invoked on every MDX - // file during scan-build, so this guard provides an extra layer. - { - name: "vinext:rsc-scan-mdx-guard", - enforce: "pre", - transform(code, id) { + const queryIndex = id.indexOf("?"); const baseId = queryIndex === -1 ? id : id.slice(0, queryIndex); - if (!baseId.endsWith(".mdx")) return null; - return { code, map: null }; + if (!baseId.endsWith(".mdx")) return; + + // If we have the MDX plugin, delegate to it + if (mdxDelegate?.transform) { + const hook = mdxDelegate.transform; + const fn = typeof hook === "function" ? hook : hook.handler; + return fn.call(this, code, id, options); + } + + // No MDX plugin registered — return a stub module so rsc:scan-strip + // doesn't choke on raw MDX frontmatter/JSX. This handles the case where + // MDX files enter the build graph via import.meta.glob but aren't inside + // app/ or pages/ (so hasMdxFiles() didn't detect them). + return { + code: `export default function MDXContent() { return null; }`, + map: null, + }; }, }, // Shim React canary/experimental APIs (ViewTransition, addTransitionType) diff --git a/tests/tsconfig-path-alias-build.test.ts b/tests/tsconfig-path-alias-build.test.ts index 10ac3bdd..25f60e55 100644 --- a/tests/tsconfig-path-alias-build.test.ts +++ b/tests/tsconfig-path-alias-build.test.ts @@ -132,14 +132,6 @@ describe("App Router tsconfig path aliases in production builds", () => { export default function HomePage() { return
home {getGlobPostCount()}
; } -`, - ); - writeFixtureFile( - root, - "app/mdx-probe/page.mdx", - `# Probe - -This file exists only to trigger vinext's MDX auto-detection in the fixture. `, ); writeFixtureFile( @@ -333,14 +325,6 @@ export default handler; export default function HomePage() { return
home {getGlobPostCount()}
; } -`, - ); - writeFixtureFile( - root, - "app/mdx-probe/page.mdx", - `# Probe - -This file exists only to trigger vinext's MDX auto-detection. `, ); writeFixtureFile( @@ -397,7 +381,15 @@ export default handler; 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("---"); + // Verify MDX was compiled to valid JS (not raw frontmatter) + // The original MDX had frontmatter lines like: + // title: "Second Post" + // date: "2025-08-20" + // These should NOT appear as raw strings in the build output + expect(buildOutput).not.toContain('title: "Second Post"'); + expect(buildOutput).not.toContain('date: "2025-08-20"'); + // JSX from the MDX file (text-red-500 className) should be compiled away + // Note: className= appears in React runtime itself, so we check the specific string expect(buildOutput).not.toContain("text-red-500"); }, 60_000); }); From f873091f23440bf891afd7503484a2c3261c73c5 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 24 Mar 2026 17:11:22 -0700 Subject: [PATCH 3/5] 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. --- packages/vinext/src/index.ts | 118 +++++---- tests/pages-router.test.ts | 55 +++- tests/tsconfig-path-alias-build.test.ts | 326 +++++++++--------------- 3 files changed, 239 insertions(+), 260 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 0204c401..b77d4fe3 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1307,9 +1307,59 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const imageImportDimCache = new Map(); - // Shared state for the MDX proxy plugin. Populated during config() if MDX - // files are detected and @mdx-js/rollup is installed. + // Shared state for the MDX proxy plugin. We auto-inject @mdx-js/rollup when + // MDX is detected in app/pages during config(), and lazily on first plain + // .mdx transform for MDX that only enters the graph via import.meta.glob. let mdxDelegate: Plugin | null = null; + let mdxDelegatePromise: Promise | null = null; + let hasUserMdxPlugin = false; + let warnedMissingMdxPlugin = false; + + async function ensureMdxDelegate(reason: "detected" | "on-demand"): Promise { + if (mdxDelegate || hasUserMdxPlugin) return mdxDelegate; + if (!mdxDelegatePromise) { + mdxDelegatePromise = (async () => { + try { + const mdxRollup = await import("@mdx-js/rollup"); + const mdxFactory = (mdxRollup.default ?? mdxRollup) as ( + options: Record, + ) => Plugin; + const mdxOpts: Record = {}; + if (nextConfig.mdx) { + if (nextConfig.mdx.remarkPlugins) mdxOpts.remarkPlugins = nextConfig.mdx.remarkPlugins; + if (nextConfig.mdx.rehypePlugins) mdxOpts.rehypePlugins = nextConfig.mdx.rehypePlugins; + if (nextConfig.mdx.recmaPlugins) mdxOpts.recmaPlugins = nextConfig.mdx.recmaPlugins; + } + const delegate = mdxFactory(mdxOpts); + mdxDelegate = delegate; + if (reason === "detected") { + if (nextConfig.mdx) { + console.log( + "[vinext] Auto-injected @mdx-js/rollup with remark/rehype plugins from next.config", + ); + } else { + console.log("[vinext] Auto-injected @mdx-js/rollup for MDX support"); + } + } else { + console.log("[vinext] Auto-injected @mdx-js/rollup for on-demand MDX support"); + } + return delegate; + } catch { + if (!warnedMissingMdxPlugin) { + warnedMissingMdxPlugin = true; + console.warn( + "[vinext] MDX files detected but @mdx-js/rollup is not installed. " + + "Install it with: " + + detectPackageManager(process.cwd()) + + " @mdx-js/rollup", + ); + } + return null; + } + })(); + } + return mdxDelegatePromise; + } const plugins: PluginOption[] = [ // Resolve tsconfig paths/baseUrl aliases so real-world Next.js repos @@ -2035,7 +2085,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Auto-inject @mdx-js/rollup when MDX files exist and no MDX plugin is // already configured. Applies remark/rehype plugins from next.config. - const hasMdxPlugin = pluginsFlat.some( + hasUserMdxPlugin = pluginsFlat.some( (p: any) => p && typeof p === "object" && @@ -2043,37 +2093,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { (p.name === "@mdx-js/rollup" || p.name === "mdx"), ); if ( - !hasMdxPlugin && + !hasUserMdxPlugin && hasMdxFiles(root, hasAppDir ? appDir : null, hasPagesDir ? pagesDir : null) ) { - try { - const mdxRollup = await import("@mdx-js/rollup"); - const mdxFactory = mdxRollup.default ?? mdxRollup; - const mdxOpts: Record = {}; - if (nextConfig.mdx) { - if (nextConfig.mdx.remarkPlugins) - mdxOpts.remarkPlugins = nextConfig.mdx.remarkPlugins; - if (nextConfig.mdx.rehypePlugins) - mdxOpts.rehypePlugins = nextConfig.mdx.rehypePlugins; - if (nextConfig.mdx.recmaPlugins) mdxOpts.recmaPlugins = nextConfig.mdx.recmaPlugins; - } - mdxDelegate = mdxFactory(mdxOpts); - if (nextConfig.mdx) { - console.log( - "[vinext] Auto-injected @mdx-js/rollup with remark/rehype plugins from next.config", - ); - } else { - console.log("[vinext] Auto-injected @mdx-js/rollup for MDX support"); - } - } catch { - // @mdx-js/rollup not installed — warn but don't fail - console.warn( - "[vinext] MDX files detected but @mdx-js/rollup is not installed. " + - "Install it with: " + - detectPackageManager(process.cwd()) + - " @mdx-js/rollup", - ); - } + await ensureMdxDelegate("detected"); } // Detect if this is a standalone SSR build (set by `vite build --ssr` @@ -2591,30 +2614,25 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const fn = typeof hook === "function" ? hook : hook.handler; return fn.call(this, config, env); }, - transform(code, id, options) { + async transform(code, id, options) { // Skip ?raw and other query imports — @mdx-js/rollup ignores the query // and would compile the file as MDX instead of returning raw text. if (id.includes("?")) return; + if (!id.endsWith(".mdx")) return; - const queryIndex = id.indexOf("?"); - const baseId = queryIndex === -1 ? id : id.slice(0, queryIndex); - if (!baseId.endsWith(".mdx")) return; - - // If we have the MDX plugin, delegate to it - if (mdxDelegate?.transform) { - const hook = mdxDelegate.transform; - const fn = typeof hook === "function" ? hook : hook.handler; - return fn.call(this, code, id, options); + const delegate = mdxDelegate ?? (await ensureMdxDelegate("on-demand")); + if (delegate?.transform) { + const hook = delegate.transform; + const transform = typeof hook === "function" ? hook : hook.handler; + return transform.call(this, code, id, options); } - // No MDX plugin registered — return a stub module so rsc:scan-strip - // doesn't choke on raw MDX frontmatter/JSX. This handles the case where - // MDX files enter the build graph via import.meta.glob but aren't inside - // app/ or pages/ (so hasMdxFiles() didn't detect them). - return { - code: `export default function MDXContent() { return null; }`, - map: null, - }; + if (!hasUserMdxPlugin) { + throw new Error( + `[vinext] Encountered MDX module ${id} but no MDX plugin is configured. ` + + `Install @mdx-js/rollup or register an MDX plugin manually.`, + ); + } }, }, // Shim React canary/experimental APIs (ViewTransition, addTransitionType) diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 675ad5eb..938f7935 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1331,7 +1331,7 @@ describe("Plugin config", () => { expect(defaultHandler).not.toHaveBeenCalled(); }); - it("registers vinext:mdx proxy plugin with enforce pre for correct ordering", () => { + it("registers vinext:mdx proxy plugin with enforce pre for correct ordering", async () => { const plugins = vinext() as any[]; const mdxProxy = plugins.find((p) => p.name === "vinext:mdx"); expect(mdxProxy).toBeDefined(); @@ -1341,10 +1341,10 @@ describe("Plugin config", () => { expect(typeof mdxProxy.transform).toBe("function"); // Proxy should be inert when no MDX files are detected (mdxDelegate is null) expect(mdxProxy.config({}, { command: "build", mode: "production" })).toBeUndefined(); - expect(mdxProxy.transform("code", "./foo.ts", {})).toBeUndefined(); + await expect(mdxProxy.transform("code", "./foo.ts", {})).resolves.toBeUndefined(); }); - it("vinext:mdx transform skips ids that contain a query string (regression: ?raw)", () => { + it("vinext:mdx transform skips ids that contain a query string (regression: ?raw)", async () => { // @mdx-js/rollup strips the query before matching the file extension, so // it would compile "foo.mdx?raw" as MDX and return compiled JSX instead of // raw text. The proxy must short-circuit on any id that contains "?". @@ -1352,9 +1352,52 @@ describe("Plugin config", () => { const mdxProxy = plugins.find((p: any) => p.name === "vinext:mdx"); // Common query-param import patterns that must be skipped - expect(mdxProxy.transform("# hello", "/app/content.mdx?raw", {})).toBeUndefined(); - expect(mdxProxy.transform("# hello", "/app/page.mdx?url", {})).toBeUndefined(); - expect(mdxProxy.transform("# hello", "/app/page.mdx?inline", {})).toBeUndefined(); + await expect( + mdxProxy.transform("# hello", "/app/content.mdx?raw", {}), + ).resolves.toBeUndefined(); + await expect(mdxProxy.transform("# hello", "/app/page.mdx?url", {})).resolves.toBeUndefined(); + await expect( + mdxProxy.transform("# hello", "/app/page.mdx?inline", {}), + ).resolves.toBeUndefined(); + }); + + it("vinext:mdx lazily compiles plain .mdx imports that were not pre-detected", async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-lazy-")); + + try { + await fsp.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ name: "vinext-mdx-lazy", private: true, type: "module" }), + ); + + const plugins = vinext({ appDir: tmpDir }) as any[]; + const configPlugin = plugins.find((p) => p.name === "vinext:config"); + const mdxProxy = plugins.find((p) => p.name === "vinext:mdx"); + + await configPlugin.config( + { root: tmpDir, plugins: [] }, + { command: "build", mode: "production" }, + ); + + const result = await mdxProxy.transform( + `--- +title: "Second Post" +--- + +export const marker = "mdx-evaluated"; + +# Hello world +`, + path.join(tmpDir, "content", "post.mdx"), + {}, + ); + + expect(result).toBeDefined(); + expect(result.code).toContain("mdx-evaluated"); + expect(result.code).not.toContain("---"); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } }); it("vinext:mdx proxy logic — ?raw guard prevents delegate from compiling query imports", () => { diff --git a/tests/tsconfig-path-alias-build.test.ts b/tests/tsconfig-path-alias-build.test.ts index 25f60e55..9460ffe6 100644 --- a/tests/tsconfig-path-alias-build.test.ts +++ b/tests/tsconfig-path-alias-build.test.ts @@ -7,6 +7,17 @@ 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); @@ -28,53 +39,38 @@ function readTextFilesRecursive(root: string): string { return output; } -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); - 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", - ); - const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as { - cloudflare: (opts?: { - viteEnvironment?: { name: string; childEnvironments?: string[] }; - }) => import("vite").Plugin; - }; +async function loadCloudflarePlugin(): Promise { + const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as { + cloudflare: CloudflarePluginFactory; + }; + return cloudflare; +} - fs.symlinkSync( - path.resolve(import.meta.dirname, "../node_modules"), - path.join(root, "node_modules"), - "junction", - ); +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: "vinext-tsconfig-alias-build", - private: true, - type: "module", - }, - null, - 2, - ), - ); - writeFixtureFile( - root, - "wrangler.jsonc", - `{ - "name": "vinext-tsconfig-alias-build", + 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", @@ -84,34 +80,34 @@ describe("App Router tsconfig path aliases in production builds", () => { } } `, - ); - 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: { - "@/*": ["./*"], - }, + ); + 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({ + include: ["app", "lib", "content", "*.ts", "*.tsx"], + }, + null, + 2, + ), + ); + writeFixtureFile( + root, + "app/layout.tsx", + `export default function RootLayout({ children, }: { children: React.ReactNode; @@ -123,7 +119,50 @@ describe("App Router tsconfig path aliases in production builds", () => { ); } `, - ); + ); + writeFixtureFile( + root, + "mdx-components.tsx", + `export function useMDXComponents(components: Record) { + 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", @@ -132,6 +171,14 @@ describe("App Router tsconfig path aliases in production builds", () => { export default function HomePage() { return
home {getGlobPostCount()}
; } +`, + ); + writeFixtureFile( + root, + "app/mdx-probe/page.mdx", + `# Probe + +This file exists only to trigger vinext's MDX auto-detection in the fixture. `, ); writeFixtureFile( @@ -201,27 +248,10 @@ This content came from an alias-based MDX import. `# Dynamic MDX Post This content came from an alias-based dynamic MDX import. -`, - ); - writeFixtureFile( - root, - "worker/index.ts", - `import handler from ${JSON.stringify(workerEntryPath)}; - -export default handler; `, ); - const builder = await createBuilder({ - root, - configFile: false, - plugins: [ - vinext({ appDir: root }), - cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }), - ], - logLevel: "silent", - }); - await builder.buildApp(); + await buildCloudflareAppFixture(root); const buildOutput = readTextFilesRecursive(path.join(root, "dist")); expect(buildOutput).not.toContain('import.meta.glob("@/content/posts/**/*.mdx"'); @@ -231,92 +261,7 @@ export default handler; 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); - 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", - ); - const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as { - cloudflare: (opts?: { - viteEnvironment?: { name: string; childEnvironments?: string[] }; - }) => import("vite").Plugin; - }; - - fs.symlinkSync( - path.resolve(import.meta.dirname, "../node_modules"), - path.join(root, "node_modules"), - "junction", - ); - - writeFixtureFile( - root, - "package.json", - JSON.stringify( - { - name: "vinext-mdx-frontmatter-test", - private: true, - type: "module", - }, - null, - 2, - ), - ); - writeFixtureFile( - root, - "wrangler.jsonc", - `{ - "name": "vinext-mdx-frontmatter-test", - "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 ( - - {children} - - ); -} -`, - ); + writeCloudflareAppFixture(root, "vinext-mdx-frontmatter-test"); writeFixtureFile( root, "app/page.tsx", @@ -358,38 +303,11 @@ date: "2025-08-20" This is a post with frontmatter and JSX. `, ); - writeFixtureFile( - root, - "worker/index.ts", - `import handler from ${JSON.stringify(workerEntryPath)}; -export default handler; -`, - ); - - const builder = await createBuilder({ - root, - configFile: false, - plugins: [ - vinext({ appDir: root }), - cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }), - ], - logLevel: "silent", - }); - await builder.buildApp(); + 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/"); - // Verify MDX was compiled to valid JS (not raw frontmatter) - // The original MDX had frontmatter lines like: - // title: "Second Post" - // date: "2025-08-20" - // These should NOT appear as raw strings in the build output - expect(buildOutput).not.toContain('title: "Second Post"'); - expect(buildOutput).not.toContain('date: "2025-08-20"'); - // JSX from the MDX file (text-red-500 className) should be compiled away - // Note: className= appears in React runtime itself, so we check the specific string - expect(buildOutput).not.toContain("text-red-500"); }, 60_000); }); From a0a2b05b633b02250271113a006272a474b18171 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 25 Mar 2026 12:37:24 -0700 Subject: [PATCH 4/5] fix review issues --- packages/vinext/src/index.ts | 4 +++- tests/pages-router.test.ts | 2 +- tests/tsconfig-path-alias-build.test.ts | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index b77d4fe3..1602bbac 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1316,6 +1316,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let warnedMissingMdxPlugin = false; async function ensureMdxDelegate(reason: "detected" | "on-demand"): Promise { + // If user registered their own MDX plugin, don't interfere — their plugin + // will handle .mdx transforms later in the pipeline. if (mdxDelegate || hasUserMdxPlugin) return mdxDelegate; if (!mdxDelegatePromise) { mdxDelegatePromise = (async () => { @@ -1345,7 +1347,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } return delegate; } catch { - if (!warnedMissingMdxPlugin) { + if (reason === "detected" && !warnedMissingMdxPlugin) { warnedMissingMdxPlugin = true; console.warn( "[vinext] MDX files detected but @mdx-js/rollup is not installed. " + diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 938f7935..cceaa2fb 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1394,7 +1394,7 @@ export const marker = "mdx-evaluated"; expect(result).toBeDefined(); expect(result.code).toContain("mdx-evaluated"); - expect(result.code).not.toContain("---"); + expect(result.code).not.toContain('title: "Second Post"'); } finally { await fsp.rm(tmpDir, { recursive: true, force: true }); } diff --git a/tests/tsconfig-path-alias-build.test.ts b/tests/tsconfig-path-alias-build.test.ts index 9460ffe6..e822f4f7 100644 --- a/tests/tsconfig-path-alias-build.test.ts +++ b/tests/tsconfig-path-alias-build.test.ts @@ -309,5 +309,7 @@ date: "2025-08-20" 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); }); From 722a14447c26a34496bbfac43ad36e5c4ede152f Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 25 Mar 2026 12:57:26 -0700 Subject: [PATCH 5/5] 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 Made-with: Cursor --- packages/vinext/src/index.ts | 4 +++- tests/tsconfig-paths-vite8.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 1602bbac..22804550 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -348,7 +348,9 @@ function toViteAliasReplacement(absolutePath: string, projectRoot: string): stri for (const rootCandidate of rootCandidates) { for (const pathCandidate of pathCandidates) { - if (pathCandidate === rootCandidate) return "/"; + if (pathCandidate === rootCandidate) { + return normalizedPath; + } const relativeId = relativeWithinRoot(rootCandidate, pathCandidate); if (relativeId) return "/" + relativeId; } diff --git a/tests/tsconfig-paths-vite8.test.ts b/tests/tsconfig-paths-vite8.test.ts index 1a1f5543..1c4c2864 100644 --- a/tests/tsconfig-paths-vite8.test.ts +++ b/tests/tsconfig-paths-vite8.test.ts @@ -110,11 +110,11 @@ describe("Vite tsconfig paths support", () => { { command: "serve", mode: "development" }, ); - expect(resolvedConfig?.resolve?.alias).toEqual( - expect.objectContaining({ - "@": "/", - }), - ); + const alias = resolvedConfig?.resolve?.alias as Record; + expect(alias).toBeDefined(); + expect(alias["@"]).toBeDefined(); + expect(path.isAbsolute(alias["@"])).toBe(true); + expect(alias["@"].replace(/\\/g, "/")).toContain(root.replace(/\\/g, "/")); fs.rmSync(root, { recursive: true, force: true }); });