-
Notifications
You must be signed in to change notification settings - Fork 278
fix: skip MDX files during RSC scan-build to prevent parse errors #668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e38d65b
af6f644
f873091
a0a2b05
722a144
f5f2bdb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a separate behavioral change from the MDX fix and should be called out in the PR description. Previously, when the alias target resolved to the project root (e.g., The new behavior is correct — Vite's Please add a line to the PR description noting this as a companion fix. |
||||||||||||||||||||
| } | ||||||||||||||||||||
| const relativeId = relativeWithinRoot(rootCandidate, pathCandidate); | ||||||||||||||||||||
| if (relativeId) return "/" + relativeId; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
@@ -1307,9 +1309,69 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |||||||||||||||||||
|
|
||||||||||||||||||||
| const imageImportDimCache = new Map<string, { width: number; height: number }>(); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // 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; | ||||||||||||||||||||
| // Cached across calls — only the first invocation's `reason` affects logging. | ||||||||||||||||||||
| // This is correct because config() always runs before transform() in the same build. | ||||||||||||||||||||
| let mdxDelegatePromise: Promise<Plugin | null> | null = null; | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note on the caching semantics: Consider adding a comment on this line:
Suggested change
|
||||||||||||||||||||
| let hasUserMdxPlugin = false; | ||||||||||||||||||||
| let warnedMissingMdxPlugin = false; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| async function ensureMdxDelegate(reason: "detected" | "on-demand"): Promise<Plugin | null> { | ||||||||||||||||||||
| // If user registered their own MDX plugin, don't interfere — their plugin | ||||||||||||||||||||
| // will handle .mdx transforms later in the pipeline. | ||||||||||||||||||||
| // Note: hasUserMdxPlugin is set during config() which always runs before transform(). | ||||||||||||||||||||
| // When true, we return null (mdxDelegate is null) and the caller silently | ||||||||||||||||||||
| // returns undefined to let the user's plugin handle the file. | ||||||||||||||||||||
| if (mdxDelegate || hasUserMdxPlugin) return mdxDelegate; | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The control flow here is correct but subtle. When A brief comment would help readability:
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: the comment block above (lines 1323-1327) is good and addresses earlier review feedback about this subtle control flow. One nit — the comment says "When true, we return null (mdxDelegate is null)" but technically if |
||||||||||||||||||||
| if (!mdxDelegatePromise) { | ||||||||||||||||||||
| mdxDelegatePromise = (async () => { | ||||||||||||||||||||
| try { | ||||||||||||||||||||
| const mdxRollup = await import("@mdx-js/rollup"); | ||||||||||||||||||||
| const mdxFactory = (mdxRollup.default ?? mdxRollup) as ( | ||||||||||||||||||||
| options: Record<string, unknown>, | ||||||||||||||||||||
| ) => Plugin; | ||||||||||||||||||||
| const mdxOpts: Record<string, unknown> = {}; | ||||||||||||||||||||
| 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") { | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The However, the |
||||||||||||||||||||
| 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 { | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When But there's a subtle issue: the
The current behavior (warn + throw) is fine — the throw is what actually prevents the broken build — but the double messaging is slightly noisy. |
||||||||||||||||||||
| // Only warn during "detected" path (MDX files in app/pages at config time). | ||||||||||||||||||||
| // For "on-demand" (MDX encountered during transform), the error thrown | ||||||||||||||||||||
| // in transform() is more actionable and immediate. Avoid double messaging. | ||||||||||||||||||||
| if (reason === "detected" && !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,45 +2097,18 @@ 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" && | ||||||||||||||||||||
| typeof p.name === "string" && | ||||||||||||||||||||
| (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<string, unknown> = {}; | ||||||||||||||||||||
| 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,14 +2626,27 @@ 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 (!mdxDelegate?.transform) return; | ||||||||||||||||||||
| const hook = mdxDelegate.transform; | ||||||||||||||||||||
| const fn = typeof hook === "function" ? hook : hook.handler; | ||||||||||||||||||||
| return fn.call(this, code, id, options); | ||||||||||||||||||||
| // Case-insensitive extension check for cross-platform compatibility | ||||||||||||||||||||
| // (Windows/macOS case-insensitive, Linux case-sensitive) | ||||||||||||||||||||
| if (!id.toLowerCase().endsWith(".mdx")) return; | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The case-insensitive check ( |
||||||||||||||||||||
|
|
||||||||||||||||||||
| const delegate = mdxDelegate ?? (await ensureMdxDelegate("on-demand")); | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||||||||||||||
| if (delegate?.transform) { | ||||||||||||||||||||
| const hook = delegate.transform; | ||||||||||||||||||||
| const transform = typeof hook === "function" ? hook : hook.handler; | ||||||||||||||||||||
| return transform.call(this, code, id, options); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| 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) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,20 +1341,83 @@ 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 "?". | ||
| const plugins = vinext() as any[]; | ||
| 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 <span>world</span> | ||
| `, | ||
| path.join(tmpDir, "content", "post.mdx"), | ||
| {}, | ||
| ); | ||
|
|
||
| expect(result).toBeDefined(); | ||
| expect(result.code).toContain("mdx-evaluated"); | ||
| expect(result.code).not.toContain('title: "Second Post"'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good improvement over the earlier |
||
| } finally { | ||
| await fsp.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| it("skips MDX files with various query parameters", async () => { | ||
| const plugins = vinext() as any[]; | ||
| const mdxProxy = plugins.find((p: any) => p.name === "vinext:mdx"); | ||
|
|
||
| // All query variants should be skipped (return undefined) | ||
| await expect( | ||
| mdxProxy.transform("# hello", "/app/content.mdx?raw", {}), | ||
| ).resolves.toBeUndefined(); | ||
| await expect(mdxProxy.transform("# hello", "/app/page.mdx?v=123", {})).resolves.toBeUndefined(); | ||
| await expect( | ||
| mdxProxy.transform("# hello", "/app/page.mdx?inline", {}), | ||
| ).resolves.toBeUndefined(); | ||
| await expect(mdxProxy.transform("# hello", "/app/page.mdx?url", {})).resolves.toBeUndefined(); | ||
| await expect(mdxProxy.transform("# hello", "/app/page.mdx?mdx", {})).resolves.toBeUndefined(); | ||
| // Edge case: query value contains .mdx but isn't the extension | ||
| await expect( | ||
| mdxProxy.transform("# hello", "/app/page.mdx?something.mdx", {}), | ||
| ).resolves.toBeUndefined(); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test ( Consider either:
Duplicate test cases increase maintenance cost without increasing coverage. |
||
|
|
||
| it("vinext:mdx proxy logic — ?raw guard prevents delegate from compiling query imports", () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a meaningful behavioral change that's easy to miss in the diff. Previously, when the alias target resolved to the project root (e.g.,
@/* -> ./*withbaseUrl: "."), this returned"/". Now it returns the full normalized absolute path.The old
"/"was problematic because Vite'sresolve.aliascan interpret"/"as the filesystem root, not the project root. Returning the actual absolute path is correct.However, this change affects all tsconfig alias resolution, not just the MDX/test scenario. It should be called out in the PR description since it's a separate fix from the MDX issue.