Skip to content

Commit fef54db

Browse files
authored
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>
1 parent f8a28fd commit fef54db

4 files changed

Lines changed: 309 additions & 134 deletions

File tree

packages/vinext/src/index.ts

Lines changed: 87 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ function toViteAliasReplacement(absolutePath: string, projectRoot: string): stri
203203

204204
for (const rootCandidate of rootCandidates) {
205205
for (const pathCandidate of pathCandidates) {
206-
if (pathCandidate === rootCandidate) return "/";
206+
if (pathCandidate === rootCandidate) {
207+
return normalizedPath;
208+
}
207209
const relativeId = relativeWithinRoot(rootCandidate, pathCandidate);
208210
if (relativeId) return "/" + relativeId;
209211
}
@@ -1039,9 +1041,69 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
10391041

10401042
const imageImportDimCache = new Map<string, { width: number; height: number }>();
10411043

1042-
// Shared state for the MDX proxy plugin. Populated during config() if MDX
1043-
// files are detected and @mdx-js/rollup is installed.
1044+
// Shared state for the MDX proxy plugin. We auto-inject @mdx-js/rollup when
1045+
// MDX is detected in app/pages during config(), and lazily on first plain
1046+
// .mdx transform for MDX that only enters the graph via import.meta.glob.
10441047
let mdxDelegate: Plugin | null = null;
1048+
// Cached across calls — only the first invocation's `reason` affects logging.
1049+
// This is correct because config() always runs before transform() in the same build.
1050+
let mdxDelegatePromise: Promise<Plugin | null> | null = null;
1051+
let hasUserMdxPlugin = false;
1052+
let warnedMissingMdxPlugin = false;
1053+
1054+
async function ensureMdxDelegate(reason: "detected" | "on-demand"): Promise<Plugin | null> {
1055+
// Reuse the auto-injected delegate once it has been created.
1056+
// If the user registered their own MDX plugin and `mdxDelegate` is still null,
1057+
// return null here so transform() falls through without handling the file and
1058+
// the user's plugin can process the .mdx module later in the pipeline.
1059+
// Note: hasUserMdxPlugin is set during config(), which runs before transform().
1060+
if (mdxDelegate || hasUserMdxPlugin) return mdxDelegate;
1061+
if (!mdxDelegatePromise) {
1062+
mdxDelegatePromise = (async () => {
1063+
try {
1064+
const mdxRollup = await import("@mdx-js/rollup");
1065+
const mdxFactory = (mdxRollup.default ?? mdxRollup) as (
1066+
options: Record<string, unknown>,
1067+
) => Plugin;
1068+
const mdxOpts: Record<string, unknown> = {};
1069+
if (nextConfig.mdx) {
1070+
if (nextConfig.mdx.remarkPlugins) mdxOpts.remarkPlugins = nextConfig.mdx.remarkPlugins;
1071+
if (nextConfig.mdx.rehypePlugins) mdxOpts.rehypePlugins = nextConfig.mdx.rehypePlugins;
1072+
if (nextConfig.mdx.recmaPlugins) mdxOpts.recmaPlugins = nextConfig.mdx.recmaPlugins;
1073+
}
1074+
const delegate = mdxFactory(mdxOpts);
1075+
mdxDelegate = delegate;
1076+
if (reason === "detected") {
1077+
if (nextConfig.mdx) {
1078+
console.log(
1079+
"[vinext] Auto-injected @mdx-js/rollup with remark/rehype plugins from next.config",
1080+
);
1081+
} else {
1082+
console.log("[vinext] Auto-injected @mdx-js/rollup for MDX support");
1083+
}
1084+
} else {
1085+
console.log("[vinext] Auto-injected @mdx-js/rollup for on-demand MDX support");
1086+
}
1087+
return delegate;
1088+
} catch {
1089+
// Only warn during "detected" path (MDX files in app/pages at config time).
1090+
// For "on-demand" (MDX encountered during transform), the error thrown
1091+
// in transform() is more actionable and immediate. Avoid double messaging.
1092+
if (reason === "detected" && !warnedMissingMdxPlugin) {
1093+
warnedMissingMdxPlugin = true;
1094+
console.warn(
1095+
"[vinext] MDX files detected but @mdx-js/rollup is not installed. " +
1096+
"Install it with: " +
1097+
detectPackageManager(process.cwd()) +
1098+
" @mdx-js/rollup",
1099+
);
1100+
}
1101+
return null;
1102+
}
1103+
})();
1104+
}
1105+
return mdxDelegatePromise;
1106+
}
10451107

10461108
const plugins: PluginOption[] = [
10471109
// Resolve tsconfig paths/baseUrl aliases so real-world Next.js repos
@@ -1348,45 +1410,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
13481410

13491411
// Auto-inject @mdx-js/rollup when MDX files exist and no MDX plugin is
13501412
// already configured. Applies remark/rehype plugins from next.config.
1351-
const hasMdxPlugin = pluginsFlat.some(
1413+
hasUserMdxPlugin = pluginsFlat.some(
13521414
(p: any) =>
13531415
p &&
13541416
typeof p === "object" &&
13551417
typeof p.name === "string" &&
13561418
(p.name === "@mdx-js/rollup" || p.name === "mdx"),
13571419
);
13581420
if (
1359-
!hasMdxPlugin &&
1421+
!hasUserMdxPlugin &&
13601422
hasMdxFiles(root, hasAppDir ? appDir : null, hasPagesDir ? pagesDir : null)
13611423
) {
1362-
try {
1363-
const mdxRollup = await import("@mdx-js/rollup");
1364-
const mdxFactory = mdxRollup.default ?? mdxRollup;
1365-
const mdxOpts: Record<string, unknown> = {};
1366-
if (nextConfig.mdx) {
1367-
if (nextConfig.mdx.remarkPlugins)
1368-
mdxOpts.remarkPlugins = nextConfig.mdx.remarkPlugins;
1369-
if (nextConfig.mdx.rehypePlugins)
1370-
mdxOpts.rehypePlugins = nextConfig.mdx.rehypePlugins;
1371-
if (nextConfig.mdx.recmaPlugins) mdxOpts.recmaPlugins = nextConfig.mdx.recmaPlugins;
1372-
}
1373-
mdxDelegate = mdxFactory(mdxOpts);
1374-
if (nextConfig.mdx) {
1375-
console.log(
1376-
"[vinext] Auto-injected @mdx-js/rollup with remark/rehype plugins from next.config",
1377-
);
1378-
} else {
1379-
console.log("[vinext] Auto-injected @mdx-js/rollup for MDX support");
1380-
}
1381-
} catch {
1382-
// @mdx-js/rollup not installed — warn but don't fail
1383-
console.warn(
1384-
"[vinext] MDX files detected but @mdx-js/rollup is not installed. " +
1385-
"Install it with: " +
1386-
detectPackageManager(process.cwd()) +
1387-
" @mdx-js/rollup",
1388-
);
1389-
}
1424+
await ensureMdxDelegate("detected");
13901425
}
13911426

13921427
// Detect if this is a standalone SSR build (set by `vite build --ssr`
@@ -1972,14 +2007,27 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
19722007
const fn = typeof hook === "function" ? hook : hook.handler;
19732008
return fn.call(this, config, env);
19742009
},
1975-
transform(code, id, options) {
2010+
async transform(code, id, options) {
19762011
// Skip ?raw and other query imports — @mdx-js/rollup ignores the query
19772012
// and would compile the file as MDX instead of returning raw text.
19782013
if (id.includes("?")) return;
1979-
if (!mdxDelegate?.transform) return;
1980-
const hook = mdxDelegate.transform;
1981-
const fn = typeof hook === "function" ? hook : hook.handler;
1982-
return fn.call(this, code, id, options);
2014+
// Case-insensitive extension check for cross-platform compatibility
2015+
// (Windows/macOS case-insensitive, Linux case-sensitive)
2016+
if (!id.toLowerCase().endsWith(".mdx")) return;
2017+
2018+
const delegate = mdxDelegate ?? (await ensureMdxDelegate("on-demand"));
2019+
if (delegate?.transform) {
2020+
const hook = delegate.transform;
2021+
const transform = typeof hook === "function" ? hook : hook.handler;
2022+
return transform.call(this, code, id, options);
2023+
}
2024+
2025+
if (!hasUserMdxPlugin) {
2026+
throw new Error(
2027+
`[vinext] Encountered MDX module ${id} but no MDX plugin is configured. ` +
2028+
`Install @mdx-js/rollup or register an MDX plugin manually.`,
2029+
);
2030+
}
19832031
},
19842032
},
19852033
// Shim React canary/experimental APIs (ViewTransition, addTransitionType)
@@ -3870,7 +3918,7 @@ function scanDirForMdx(dir: string): boolean {
38703918
const full = path.join(dir, entry.name);
38713919
if (entry.isDirectory()) {
38723920
if (scanDirForMdx(full)) return true;
3873-
} else if (entry.isFile() && entry.name.endsWith(".mdx")) {
3921+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".mdx")) {
38743922
return true;
38753923
}
38763924
}

tests/pages-router.test.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1474,7 +1474,7 @@ describe("Plugin config", () => {
14741474
expect(defaultHandler).not.toHaveBeenCalled();
14751475
});
14761476

1477-
it("registers vinext:mdx proxy plugin with enforce pre for correct ordering", () => {
1477+
it("registers vinext:mdx proxy plugin with enforce pre for correct ordering", async () => {
14781478
const plugins = vinext() as any[];
14791479
const mdxProxy = plugins.find((p) => p.name === "vinext:mdx");
14801480
expect(mdxProxy).toBeDefined();
@@ -1484,20 +1484,70 @@ describe("Plugin config", () => {
14841484
expect(typeof mdxProxy.transform).toBe("function");
14851485
// Proxy should be inert when no MDX files are detected (mdxDelegate is null)
14861486
expect(mdxProxy.config({}, { command: "build", mode: "production" })).toBeUndefined();
1487-
expect(mdxProxy.transform("code", "./foo.ts", {})).toBeUndefined();
1487+
await expect(mdxProxy.transform("code", "./foo.ts", {})).resolves.toBeUndefined();
14881488
});
14891489

1490-
it("vinext:mdx transform skips ids that contain a query string (regression: ?raw)", () => {
1490+
it("vinext:mdx transform skips ids that contain a query string (regression: ?raw)", async () => {
14911491
// @mdx-js/rollup strips the query before matching the file extension, so
14921492
// it would compile "foo.mdx?raw" as MDX and return compiled JSX instead of
14931493
// raw text. The proxy must short-circuit on any id that contains "?".
14941494
const plugins = vinext() as any[];
14951495
const mdxProxy = plugins.find((p: any) => p.name === "vinext:mdx");
14961496

14971497
// Common query-param import patterns that must be skipped
1498-
expect(mdxProxy.transform("# hello", "/app/content.mdx?raw", {})).toBeUndefined();
1499-
expect(mdxProxy.transform("# hello", "/app/page.mdx?url", {})).toBeUndefined();
1500-
expect(mdxProxy.transform("# hello", "/app/page.mdx?inline", {})).toBeUndefined();
1498+
await expect(
1499+
mdxProxy.transform("# hello", "/app/content.mdx?raw", {}),
1500+
).resolves.toBeUndefined();
1501+
await expect(mdxProxy.transform("# hello", "/app/page.mdx?url", {})).resolves.toBeUndefined();
1502+
await expect(
1503+
mdxProxy.transform("# hello", "/app/page.mdx?inline", {}),
1504+
).resolves.toBeUndefined();
1505+
// Additional query variations
1506+
await expect(mdxProxy.transform("# hello", "/app/page.mdx?v=123", {})).resolves.toBeUndefined();
1507+
await expect(mdxProxy.transform("# hello", "/app/page.mdx?mdx", {})).resolves.toBeUndefined();
1508+
// Edge case: query value contains .mdx but isn't the extension
1509+
await expect(
1510+
mdxProxy.transform("# hello", "/app/page.mdx?something.mdx", {}),
1511+
).resolves.toBeUndefined();
1512+
});
1513+
1514+
it("vinext:mdx lazily compiles plain .mdx imports that were not pre-detected", async () => {
1515+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-lazy-"));
1516+
1517+
try {
1518+
await fsp.writeFile(
1519+
path.join(tmpDir, "package.json"),
1520+
JSON.stringify({ name: "vinext-mdx-lazy", private: true, type: "module" }),
1521+
);
1522+
1523+
const plugins = vinext({ appDir: tmpDir }) as any[];
1524+
const configPlugin = plugins.find((p) => p.name === "vinext:config");
1525+
const mdxProxy = plugins.find((p) => p.name === "vinext:mdx");
1526+
1527+
await configPlugin.config(
1528+
{ root: tmpDir, plugins: [] },
1529+
{ command: "build", mode: "production" },
1530+
);
1531+
1532+
const result = await mdxProxy.transform(
1533+
`---
1534+
title: "Second Post"
1535+
---
1536+
1537+
export const marker = "mdx-evaluated";
1538+
1539+
# Hello <span>world</span>
1540+
`,
1541+
path.join(tmpDir, "content", "post.mdx"),
1542+
{},
1543+
);
1544+
1545+
expect(result).toBeDefined();
1546+
expect(result.code).toContain("mdx-evaluated");
1547+
expect(result.code).not.toContain('title: "Second Post"');
1548+
} finally {
1549+
await fsp.rm(tmpDir, { recursive: true, force: true });
1550+
}
15011551
});
15021552

15031553
it("vinext:mdx proxy logic — ?raw guard prevents delegate from compiling query imports", () => {

0 commit comments

Comments
 (0)