MDX (Unified) Mutating Options Object Cost Me 2 Hours
Minh-Phuc Tran
Posted on January 28, 2021
A couple of days ago, I got into a very annoying issue while using MDX in my Next.js website. It cost me almost 2 hours to resolve.
Context
First, let's quickly go through some technical concepts in case you didn't work with MDX and Next.js a lot:
MDX is essentially a set of unified plugins. unified is a generic interface for processing content as structured data. Thanks to this, I was able to write granular plugins to customize how I use MDX quite extensively.
Next.js is built on top of Webpack and loads MDX from a Webpack loader (
@mdx-js/loader
).I have different plugins and configurations for different MDX documents based on their file paths so that I can have custom syntaxes for different types of documents.
In order to achieve that I have a custom Next.js plugin that will resolve into different MDX options for different documents:
const configureMDX = ({ realResource }) => {
if (realResource.startsWith(folders.blog)) return configs.blog;
if (realResource.startsWith(folders.cheatsheet)) return configs.cheatsheet;
return configs.base;
};
module.exports = (next = {}) =>
Object.assign({}, next, {
webpack(config, appOptions) {
config.module.rules.push({
test: /\.(md|mdx)$/,
use: (info) => [
appOptions.defaultLoaders.babel,
{
loader: require.resolve("@mdx-js/loader"),
options: configureMDX(info),
},
],
});
if (typeof next.webpack === "function") {
return next.webpack(config, appOptions);
}
return config;
},
});
configs.base
, configs.blog
, and configs.cheatsheet
are just typical MDX options:
// configs.base
module.exports = {
remarkPlugins: [
frontmatter,
parseFrontmatter,
[
extractFrontmatter,
{
title: { type: "string" },
description: { type: "string" },
},
],
unwrapTexts,
titleFromContents,
descriptionFromContents,
pageURLElements,
[namedExports, ["title", "description", "url", "path", "folder", "slug"]],
],
rehypePlugins: [prism, a11yEmojis],
};
// configs.blog
module.exports = {
remarkPlugins: [
frontmatter,
parseFrontmatter,
[
extractFrontmatter,
{
title: { type: "string" },
description: { type: "string" },
date: { type: "string", format: "date", required: true },
tags: {
type: "array",
items: { type: "string", minLength: 1, required: true },
uniqueItems: true,
maxItems: 4,
},
cover: {
type: "object",
properties: {
url: { type: "string", format: "url" },
icons: {
type: "array",
items: { type: "string", minLength: 1, required: true },
uniqueItems: true,
maxItems: 3,
},
},
},
},
],
unwrapTexts,
titleFromContents,
descriptionFromContents,
pageURLElements,
generatedCover,
[
namedExports,
[
"title",
"description",
"url",
"path",
"folder",
"slug",
"date",
"tags",
"cover",
],
],
[defaultExport, "~/layouts/blog"],
],
rehypePlugins: [prism, a11yEmojis],
};
It's quite natural, right? Indeed, it worked just fine with Next.js dev server. It only failed when building for production.
The issue
Basically, I used the plugin extractFrontmatter
to both validate and expose
attributes from frontmatter as props to my layout component. Only blog documents
required date
attribute. Nonetheless, when I built for production, all documents required all attributes from different configurations combined! It was as if someone merged all the configurations together before executing the build process, despite the fact that the configuration code I wrote is completely side-effect free - all functions are pure and just return values without modifying anything.
I started to look into @mdx-js/loader
code, then @mdx-js/mdx
code, and they all looked just fine.
So, I had to debug further to see when the options got modified (I actually just
did console.log
).
All values returned from my configureMDX
are correct, so there was nothing wrong here. These values will then be sent to @mdx-js/loader
invocations and it was magically modified somehow right at the beginning of @mdx-js/loader
.
I really had no idea how it worked this time and just did tons of different guesses, made changes upon, and saw how it turned out 😥.
The fix
Thank god! After ~2 hours, I had (probably) a correct guess and managed to fix the issue.
Webpack code didn't look like modifying anything (although the logs showed changes happened right at the beginning of a Webpack loader), MDX code didn't look like modifying anything either, so I guessed Unified did it. I jumped into unified repository, and... yeah, it mutated the plugin options 🥶.
function addPlugin(plugin, value) {
var entry = find(plugin);
if (entry) {
if (plain(entry[1]) && plain(value)) {
value = extend(entry[1], value); // this equals Object.assign(...)
}
entry[1] = value;
} else {
attachers.push(slice.call(arguments));
}
}
But really? All returned values from my configureMDX
are correct, when this mutation takes place? I'm still not sure, at this time, I really just want to fix the issue and get rid of it.
So, to avoid the mutation, I simply changed my configuration code from objects to functions returning the object, this way all mutations will be discarded:
const configureMDX = ({ realResource }) => {
if (realResource.startsWith(folders.blog)) return configs.blog();
if (realResource.startsWith(folders.cheatsheet)) return configs.cheatsheet();
return configs.base();
};
My guess was that Next.js or Webpack resolves configurations for every file before invoking loaders, this way all values returned by configureMDX
are correct before going into loaders, then right after the first loader execution, it got mutated.
Final thought
This post isn't to blame anyone, I really enjoy using the Unified and MDX so far, and I appreciate the authors' works a lot. This post is just a rare story that I think other developer folks may find interesting. The lesson from this is to implement your code in a way that as side-effect free as possible, because it makes the flow crystal clear and intuitive, side effects make debugging very hard! When you can't avoid side effects, make sure to document and highlight it!
Posted on January 28, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.