Long ago I gave up the ways of customizing webpack and embraced create-react-app (CRA) as a someone-else-has-solved-it-for-me way to live. It's been great, truly. But could it be better? At least Rich Harris seems to think so— he points to Snowpack (or similarly minded tech) as the way forward.
What makes Snowpack special? According to snowpack.dev:
Snowpack leverages JavaScript’s native module system (known as ESM) to create a first-of-its-kind build system that never builds the same file twice. Snowpack pushes changes instantly to the browser, saving you hours of development time traditionally spent waiting around for your bundler.
This architecture makes Snowpack fantastic for fast rebuilds during dev, but leaves work to be done for production builds if you want your coded bundled. To do so requires configuring the @snowpack/plugin-webpack, which I'm entirely uninterested in doing. But hey, CRA does a good enough job with bundling prod builds, so why not keep using it for prod and only use Snowpack for dev? That is the setup this post aims to share.
Turns out, not very much. Feel free to jump ahead and view the end product with all required changes as a PR on GitHub.
npm install snowpack @snowpack/app-scripts-react
Note that @snowpack/app-scripts-react includes support for dotenv and react fast refresh. If you're using SCSS or other extensions, you may have to add more plugins.
The most basic snowpack.config.js
contains the following:
module.exports = {
extends: '@snowpack/app-scripts-react',
devOptions: {
port: 3000,
src: 'src',
bundle: false,
fallback: 'index.html',
},
installOptions: {
polyfillNode: true,
},
};
The example in the repo additionally shows how to configure a proxy, where to add additional plugins, and how to emulate baseUrl: "."
from a jsconfig.json.
This step is only required if you do not use the .jsx
extension for files containing JSX. If you put JSX in .js
files, you'll need babel to build your code, which Snowpack knows to use by the presence of this file (or .babelrc
). If all your JSX lives in .jsx
files, Snowpack can handle it directly without any babel config.
{
"presets": ["@babel/preset-react"]
}
Your app may work fine without making these changes, but they were a bit of a struggle to get right, so I'm sharing them here. In order to match create-react-app's CSS transformations and interpretations of @import
s, we need to use postcss.
First, we need to install some dependencies:
npm install postcss-cli postcss-flexbugs-fixes postcss-import postcss-normalize postcss-preset-env
Note: PostCSS 8 only recently came out and not everything is updated to work with it yet, so I actually specified postcss@^7.1.2 and postcss-import@^12.0.1 when I wrote this.
Next, we modify snowpack.config.js to include plugins
module.exports = {
...
plugins: [
// use postcss to get relative imports working and to match CRA
[
'@snowpack/plugin-build-script',
{ cmd: 'postcss $FILE', input: ['.css'], output: ['.css'] },
],
],
}
Finally, we add postcss.config.js
module.exports = (ctx) => {
return {
plugins: {
// need this to get relative @import "./Foo.css"; working
'postcss-import': { root: ctx.file.dirname },
// copied from create-react-app
'postcss-flexbugs-fixes': {},
'postcss-preset-env': {
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
},
'postcss-normalize': {},
},
};
};
You'll see this is a bit atypical for a postcss config, but we need to know the current file when running postcss-import to get relative imports resolved correctly. This is also why we set postcss $FILE
as the cmd in snowpack.config.js above as opposed to just postcss
as the Snowpack docs show.
I keep the CRA start script around just in case, but you shouldn't need it anymore.
"start": "snowpack dev --config snowpack.config.js",
"start:cra": "react-scripts start",
Things get a bit weird here. What we want is code that the CRA minifier will remove during production builds, but will still be run when using the snowpack dev server.
The trick is to use a snowpack specific interpolation variable called %MODE%
(essentially the same as process.env.NODE_ENV
). This interpolation only exists in the snowpack dev server, not in CRA, so the if statement will be analyzed as dead code and be pruned during the prod builds.
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
add entry for snowpack, only used in dev, it is pruned in production.
-->
<script>
if ('%MODE%' === 'development') {
// we wrap this in a function to prevent the variables from leaking out
(function() {
console.log('[dev] injecting snowpack entry')
var snowpackEntryScript = document.createElement('script');
snowpackEntryScript.setAttribute('type', 'module');
snowpackEntryScript.setAttribute('src', '/_dist_/index.js');
document.head.appendChild(snowpackEntryScript);
}())
}
</script>
</body>
If you view the index.html after running a CRA build, you'll see that the script was mostly pruned. It is rendered as <script>0</script>
, effectively doing nothing. Not quite as good as "not there at all," but close enough for me.
Is it really a good idea to use a different build system for dev and production? Maybe not, but it sure is fun to try out. I hope the steps above have helped you figure out how to tempt fate and give Snowpack a shot.
Thanks for reading! If you've got any questions, feel free to reach out to me on twitter @pbesh.