Skip to content

fix: register MDX plugin at top level to fix transform ordering#160

Open
NathanDrake2406 wants to merge 1 commit intocloudflare:mainfrom
NathanDrake2406:fix/mdx-plugin-ordering
Open

fix: register MDX plugin at top level to fix transform ordering#160
NathanDrake2406 wants to merge 1 commit intocloudflare:mainfrom
NathanDrake2406:fix/mdx-plugin-ordering

Conversation

@NathanDrake2406
Copy link

@NathanDrake2406 NathanDrake2406 commented Feb 27, 2026

Summary

  • Fix auto-injected @mdx-js/rollup running too late in the Vite plugin pipeline, causing MDX files to fail to compile
  • Register a vinext:mdx proxy plugin in the top-level plugin array with enforce: 'pre' instead of returning the MDX plugin from the config() hook
  • Proxy delegates resolveId and transform to the real @mdx-js/rollup instance, which is created during config() and stored in a shared variable
  • Proxy is inert (no-ops) when no MDX files are detected

Fixes #77

Details

Root cause: The MDX plugin was returned from vinext:config's config() hook via viteConfig.plugins. Plugins merged from config() hooks land late in the pipeline — after vite:import-analysis has already tried (and failed) to process .mdx imports.

Fix: Instead of returning the plugin from config(), a vinext:mdx proxy plugin is registered directly in the top-level plugin array returned by vinext(). It has enforce: 'pre', guaranteeing its resolveId and transform hooks run before vite:import-analysis. The proxy handles both function-style and object-style Rollup hooks (i.e. { handler, order } form).

Test plan

  • Build passes (pnpm run build)
  • Lint passes (pnpm run lint — 0 warnings, 0 errors)
  • Typecheck passes (pnpm run typecheck)
  • Full Vitest suite passes (pnpm test — 50 files, 2051 tests passed, 3 skipped)
  • New test verifies vinext:mdx plugin exists with enforce: 'pre' and is inert when no MDX delegate is set
  • E2E: verify MDX page renders in a project with .mdx files in app/

Plugins returned from the config() hook run after vite:import-analysis,
so @mdx-js/rollup transforms never fire. Move MDX to a top-level proxy
plugin with enforce:"pre" that delegates to the lazily-created MDX
instance once config() populates it.

Fixes cloudflare#77
@NathanDrake2406 NathanDrake2406 force-pushed the fix/mdx-plugin-ordering branch from aed8241 to 62c1026 Compare March 1, 2026 07:39
@southpolesteve
Copy link
Collaborator

/bigbonk review

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 3, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@160

commit: 9aebea7

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix for the root cause — plugins returned from config() hooks do run too late in the pipeline. The proxy-delegate pattern is a reasonable way to solve this. A few issues to address:

  1. Missing config hook proxy@mdx-js/rollup has a config() hook that initializes its processors with the Vite mode (development vs production). The proxy doesn't forward it, so the delegate's config() is never called by Vite. Currently the MDX plugin has a fallback in transform() that creates processors without the mode, but this means MDX compilation always runs in production mode even during dev (no JSX dev runtime, etc.). This is a latent correctness issue.

  2. Unnecessary resolveId proxy@mdx-js/rollup doesn't have a resolveId hook (confirmed by reading its source). The proxy's resolveId delegation is dead code.

  3. Comment merge issue — The MDX proxy plugin was inserted between the vinext:react-canary comment and its plugin object, merging two unrelated comments together (lines 2191-2196).

Comment on lines +2200 to +2204
resolveId(id, importer, options) {
if (!mdxDelegate?.resolveId) return;
const hook = mdxDelegate.resolveId;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call(this, id, importer, options);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mdx-js/rollup does not have a resolveId hook — its source only exposes name, config, and transform. This entire block is dead code. Consider removing it.

Suggested change
resolveId(id, importer, options) {
if (!mdxDelegate?.resolveId) return;
const hook = mdxDelegate.resolveId;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call(this, id, importer, options);
resolveId(...args: Parameters<NonNullable<Plugin['resolveId']>>) {
if (!mdxDelegate?.resolveId) return;
const hook = mdxDelegate.resolveId;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call(this, ...args);
},

Actually, since @mdx-js/rollup has no resolveId, this should just be removed entirely to avoid confusion.

Comment on lines +2197 to +2211
{
name: "vinext:mdx",
enforce: "pre",
resolveId(id, importer, options) {
if (!mdxDelegate?.resolveId) return;
const hook = mdxDelegate.resolveId;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call(this, id, importer, options);
},
transform(code, id, options) {
if (!mdxDelegate?.transform) return;
const hook = mdxDelegate.transform;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call(this, code, id, options);
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @mdx-js/rollup plugin also has a config() hook that initializes its format-aware processors using env.mode (development vs production). Since the proxy doesn't forward config(), Vite never calls it on the delegate. The MDX plugin has a fallback inside transform() that creates processors without the mode, but this means MDX will always compile in production mode — losing JSX dev runtime support during development.

Please add a config proxy:

Suggested change
{
name: "vinext:mdx",
enforce: "pre",
resolveId(id, importer, options) {
if (!mdxDelegate?.resolveId) return;
const hook = mdxDelegate.resolveId;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call(this, id, importer, options);
},
transform(code, id, options) {
if (!mdxDelegate?.transform) return;
const hook = mdxDelegate.transform;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call(this, code, id, options);
},
{
name: "vinext:mdx",
enforce: "pre",
config(config, env) {
if (!mdxDelegate?.config) return;
const hook = mdxDelegate.config;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call(this, config, env);
},
transform(code, id, options) {
if (!mdxDelegate?.transform) return;
const hook = mdxDelegate.transform;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call(this, code, id, options);
},
},

Comment on lines 2191 to +2193
// Shim React canary/experimental APIs (ViewTransition, addTransitionType)
// that exist in Next.js's bundled React canary but not in stable React 19.
// Proxy plugin for @mdx-js/rollup. The real MDX plugin is created lazily
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The insertion merged two unrelated comments. Lines 2191-2192 are the original comment for vinext:react-canary ("Shim React canary/experimental APIs..."), and lines 2193-2196 are the new MDX proxy comment. They read as one continuous block. Add a blank line between them:

Suggested change
// Shim React canary/experimental APIs (ViewTransition, addTransitionType)
// that exist in Next.js's bundled React canary but not in stable React 19.
// Proxy plugin for @mdx-js/rollup. The real MDX plugin is created lazily
// Shim React canary/experimental APIs (ViewTransition, addTransitionType)
// that exist in Next.js's bundled React canary but not in stable React 19.
// Proxy plugin for @mdx-js/rollup. The real MDX plugin is created lazily

expect(mdxProxy).toBeDefined();
expect(mdxProxy.enforce).toBe("pre");
// Proxy should be inert when no MDX files are detected (mdxDelegate is null)
expect(mdxProxy.resolveId("./foo.ts", undefined, {})).toBeUndefined();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test verifies the proxy is inert when no delegate is set, which is good. Two suggestions:

  1. It would be more valuable to also test that when mdxDelegate is populated, the proxy actually delegates. Currently this is only verified implicitly by the (unchecked) E2E test item in the PR description.

  2. Consider also asserting the proxy has a config hook (once added per the comment above), since that's part of the contract.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 3, 2026

Review submitted. Here's a summary of the feedback I posted:

Overall: The fix correctly identifies the root cause — plugins returned from config() hooks land late in Vite's pipeline, after vite:import-analysis. The proxy-delegate pattern is a reasonable solution.

Three issues flagged:

  1. Missing config hook proxy@mdx-js/rollup has a config() hook that initializes processors with env.mode (development vs production). Without proxying it, MDX will always compile in production mode, even during dev. This loses JSX dev runtime support.

  2. Unnecessary resolveId proxy — Reading the @mdx-js/rollup source confirms it only exposes name, config, and transform hooks. The resolveId delegation in the proxy is dead code and should be removed.

  3. Comment merge issue — The new MDX proxy plugin was inserted between the vinext:react-canary comment block and its corresponding plugin object, making two unrelated comments read as one continuous block.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MDX auto-injection doesn't work - plugin runs after vite:import-analysis

2 participants