Webpack, why thy so complicated? and How to Pass a React Component to your Gatsby Plugin options
Today I learned something new (again) by trying to answer a Stackoverflow question.
if you want a plug-in to take a component as an input (either as a function/class or as a string of a path to a module) ... how can you actually use that component in your plug-in?
Before we start, let's set up a simple Gatsby project structure:
root
|-- <etc>
|-- gatsby-config.js
|-- my-component.js
|-- plugins
`-- my-custom-plugin
|--gatsby-node.js
|--gatsby-ssr.js
|--gatsby-browser.js
`--package.json
Gatsby config:
// gatsby-config.js
import
module.exports = {
plugins: [
{
resolve: 'my-custom-plugin',
options: {
componentPath: path.join(__dirname, './my-component.js')
}
}
]
}
Here's the full answer.
This seems like it should be a simple question
I had the same thought while trying this out myself. Oh boy.
TL;DR:
// gatsby-node.js
const { DefinePlugin } = require('webpack')
const path = require('path')
exports.onCreateWebpackConfig = ({ actions }, { componentPath }) => {
actions.setWebpackConfig({
plugins: [
new DefinePlugin({
'___COMPONENT___': JSON.stringify(componentPath)
})
]
})
}
// gatsby-ssr
export const onRenderBody = ({ setPreBodyComponents }) => {
const Component = require(___COMPONENT___).default
setPreBodyComponents([<Component />])
}
Long read
Gatsby config doesn't seem to pass functions around (I could have sworn it used to), so passing a React component directly to your custom plugin is out the window. It has to be a path to your component.
You didn't say if you're using the component in gatsby-node
or gatsby-browser/ssr
, but I assume it's the latter since requiring stuff dynamically in Node is dead simple:
Gatsby Node
// gatsby-node.js
function consume(component) {
const Component = require(component)
}
Node doesn't understand JSX or ESM. However, that's a different problem.
Gatsby Browser
gatsby-browser/ssr
is run with webpack, so the module format is not a problem. But import(componentPath)
won't work:
Dynamic expressions in import()
It is not possible to use a fully dynamic import statement, such as
import(foo)
. Because foo could potentially be any path to any file in your system or project.
Ok, I suppose so something like this should work:
// gatsby-browser
import('./my-dir' + componentPath)
Nope, because webpack will try to resolve this from wherever the plugin lives, i.e. node_modules
or plugins
directory & we're not about to ask our users to put their custom components in node_modules
.
What about this, then?
// gatsby-browser
import(process.cwd() + componentPath) // nope
We're right back at the beginning — webpack doesn't like a full dynamic path! And even if this works, this is a terrible idea since webpack will try to bundle the whole working directory.
Only if we could encode the path as a static string beforehand, so webpack can just read that code — like using webpack.DefinePlugin
to define environment variables. Fortunately we can do that in gatsby-node.js:
// gatsby-node.js
const { DefinePlugin } = require('webpack')
const path = require('path')
exports.onCreateWebpackConfig = ({ actions }) => {
actions.setWebpackConfig({
plugins: [
new DefinePlugin({
'___CURRENT_DIR___': JSON.stringify(process.cwd())
})
]
})
}
And finally
// gatsby-browser
// eslint throw error for unknown var, so disable it
// eslint-disable-next-line
import(___CURRENT_DIR___ + componentPath) // works, but don't do this
But since we can access user options right in gatsby-node
, let's encode the whole path:
// gatsby-node.js
const { DefinePlugin } = require('webpack')
- const path = require('path')
- exports.onCreateWebpackConfig = ({ actions }) => {
+ exports.onCreateWebpackConfig = ({ actions }, { componentPath }) => {
actions.setWebpackConfig({
plugins: [
new DefinePlugin({
- '___CURRENT_DIR___': JSON.stringify(process.cwd())
+ '___COMPONENT___': JSON.stringify(componentPath)
})
]
})
}
Back in gatsby-browser.js:
// gatsby-browser
// I pick a random API to test, can't imagine why one would import a module in this API
export const onRouteUpdate = async () => {
// eslint-disable-next-line
const { default: Component } = await import(___COMPONENT___)
console.log(Component) // works
}
Gatsby SSR
For the sake of completeness, let's try the same trick in gatby-ssr:
// gatsby-ssr
export const onRenderBody = async ({ setPreBodyComponents }) => {
// const Component = require(___COMPONENT___).default
const { default: Component } = await import(___COMPONENT___)
setPreBodyComponents([<Component />])
}
...and it failed.
Why? If one's curious enough, they might dig around Gatsby code to see how gatsby-ssr is treated differently from gatsby-browser, but alas, I don't feel like doing that.
Fear not, we still have one trick up our sleeve. Webpack's require can import module dynamically too, though not asynchronously. Since gatsby-ssr
doesn't run in the browser, I couldn't care less about asynchronicity.
export const onRenderBody = ({ setPreBodyComponents }) => {
const Component = require(___COMPONENT___).default
setPreBodyComponents([<Component />]) // works
}
And now it works.
Sharing code between gatsby-ssr & gatsby-browser
Let's say we need this component in both gatsby-ssr
and gatsby-browser
— would require(...)
works in gatsby-browser
too?
export const onRouteUpdate = async () => {
// eslint-disable-next-line
const { default: Component } = require(___COMPONENT___)
console.log(Component) // yes
}
It works.
import(..)
vs require()
While import()
does load stuff dynamically, it is more of a code-splitting tool. Here are some differences other than asynchronicity:
- using
import('./my-dir' + componentPath)
will bundle all files inside./my-dir
into a chunk. There're magic comments we can use to exclude/include stuff. require(...)
will inline the required component into whatever chunk's calling it.