rollup-plugin-hot
HMR plugin for Rollup, leveraging SystemJS
This is just a proof of concept right now. Not ready for production by any measure.
It's using a WIP branch of SystemJS (thanks dudes!).
Work in progress
This is very much a work in progress. Like I'm not even so sure about what should be the next steps. Please chime in if you have any suggestion.
In particular, I don't really know how the forcing to SystemJS format will work, and how this plugin in general will interact with varied other plugins and options in real world setups.
So don't hesitate to report issues with mere success stories about your specific Rollup config, if you feel like it. Or, more likely, with feature requests to support it.
Try it
git clone git@github.com:rixo/rollup-plugin-hot.git
cd rollup-plugin-hot/example
yarn
yarn dev
Load http://localhost:5000 in your browser.
Edit files in example/src
.
Open http://localhost:5000/stateful.html for an advanced example on how to implement a stateful HMR adapter (you'll find more information about this in ./example/src/stateful/README.md, and comments in the ./example/src/stateful/hmr-adapter.js file).
Install
npm install --dev rollup-plugin-hot
Or:
yarn add --dev rollup-plugin-hot
Config
export default {
output: {
dir: 'public',
file: 'public/bundle.js',
format: 'iife',
sourcemap: true,
},
plugins: [
...hmr({
enabled: true,
hot: false,
public: 'public',
baseUrl: '/',
port: 12345,
host: '0.0.0.0',
randomPortFallback: false,
open: 'default',
openPage: '/different/page',
openHost: 'localhost',
openPort: '33000',
proxy: {
'/api/01': 'https://pokeapi.co/api/v1',
'/api/02': ['https://pokeapi.co/api/v2', { proxyReqPathResolver(req) { } }],
},
mount: {
public: '/',
'relative/path/to/somewhere': '/base-url/',
},
inMemory: true,
write: true,
reload: false,
reload: {
unaccepted: true,
moduleError: 'defer',
acceptError: true,
error: true,
reconnect: true,
},
autoAccept: false,
clearConsole: true,
loaderFile: 'public/bundle.js',
}),
],
}
How it works
This plugin leverages SystemJS as a module loader (and reloader!) in the browser, and Rollup's preserveModules
option (to be able to reload individual modules and not the whole bundle).
The plugin itself fires up a dev server to notify System of the changes than happen in Rollup.
It injects the SystemJS loader and the HMR runtime in the browser by writing them in place of your entrypoints.
For example, with the following Rollup config:
input: 'src/main.js',
output: {
file: 'public/bundle.js',
},
plugins: [
hmr({
public: 'public',
baseUrl: '/',
})
]
The plugin will write only HMR machinery in public/bundle.js
and add a single import at the end, pointing to the module containing your actual code (in the "@hot bundle" directory):
...
...
System.import('/bundle.js@hot/main.js')
The precise location of the @hot directory changes depending on whether you're using output.file
(outputFile.js@hot/
) or output.dir
(outputDir/@hot
), and the value of preserveModules
(entry point renamed to outputDir/entry@hot.js
) in your Rollup config.
But the main idea is to have a 1:1 mapping between the layout of your source directory and the @hot directory, so that relative imports just work normally. Also, since the plugin never injects anything in modules containing your own code, Rollup generated source maps are also completely unaffected.
There's also a inMemory
option that stores Rollup's output files to RAM instead of writing them to disk, and serves them through the dev server. It is better for perf and for your HDD, but it can be more subject to cross-origin issues. To be frank, the WebSocket probably already suffers from such issues...
The "hot API"
One of the very open question of this implementation is what should the "hot API" be like? That is, the API that you use in your application (or most likely a framework specific HMR plugin) to apply HMR update at "app level".
In Webpack, it looks like this:
const previousDisposeData = module.hot.data
module.hot.dispose(data => { ... })
module.hot.accept(errorHandler)
module.hot.decline()
As far as I can tell, Parcel implements a subset of this (accept
, dispose
, and status). My guess is that they took what was needed for compatibility of React hot loader or something like this but, truly, I don't know.
What this plugin currently implements is this:
const previousDisposeData = import.meta.hot.data
import.meta.hot.dispose(async data => { ... })
import.meta.hot.accept(async acceptHandler)
import.meta.hot.decline()
import.meta.hot.catch(async errorHandler)
We want to use import.meta
because it's close to the proposed standard, and that's probably what you want if you're using Rollup.
This plugin differs from Webpack regarding accept handlers (i.e. callbacks to module.hot.accept(callback)
). Webpack only runs the callback when there is an error during module update (i.e. they are error handlers), whereas this plugin runs a module's accept handler whenever the module is updated.
My rationale is that accept handlers gives a better control to the HMR client (runtime) over the application of an update. It lets it distinguish between errors that happens during module init (i.e. app code) of those that happens in accept handlers (i.e. HMR specific code).
Furthermore, allowing the handlers to be async gives them more power. For example, a handler can let a component finish an async cleanup phase before replacing it with a new instance. And if that goes catastrophically bad, the HMR client can catch the error and take the most appropriate measures regarding HMR state, full reload, and reporting. If that does not go bad, we still get the "Up to date" signal when the update has really been completely applied.
This gives us better error management & reporting capability overall.
Maybe decline
and/or catch
would make sense too, but I'm not so sure.
The plugin already offers a compatibility layer for Nollup with the compatNollup
option, that transforms code intended for this hot API so that it can be run by Nollup. It makes sense because Nollup is intended to run Rollup config files, of which this plugin could be a part. So a project might want to run both at different times. Or switch from one to the other at some point.
API
import.meta.hot
This is the main object exposing the HMR API. You should test if this object is present before using any other part of the API. If the object is not present, it means that HMR is not currently enabled and any HMR specific code should bail out as fast as possible.
if (import.meta.hot) {
}
Note that import.meta
is (proposed) standard in ES module and you should need no plugin for an ES module aware environment to handle this code.
import.meta.hot.data
This object is used to pass data between the old and new version of a module.
The data are provided by the dispose
handler (see bellow).
On the first run of a module (i.e. initial load, not a HMR update), this object will be undefined (this is to align with webpack, but it bothers me more and more, so it could very well change in the near future, please don't rely on this -- but ensure you don't read from an undefined object, which is what bothers me...).
import.meta.hot.dispose(async data => void)
The dispose function is called when the module is about to be replaced. The handler is passed a data object that you can mutate and that will be available to the new version of the module that is being loaded.
if (import.meta.hot) {
console.log(import.meta.hot.data)
const state = (import.meta.hot.data && import.meta.hot.data.value) || 0
import.meta.hot.dispose(data => {
data.value = state + 1
})
}
import.meta.hot.accept(function|void)
Accepts HMR updates for this module, optionally passing an accept handler.
If a module has an accept handler, then changes to this module won't trigger a full reload. If the module needs specific work to reflect the code change, it is expected to be handled by the provided accept handler function.
The order of execution when a HMR update happens is as follow:
-
the old module's dispose
handler is called
-
the new module is executed
-
the old module's accept
handler is called
import.meta.hot.accept()
import.meta.hot.accept(({ id, bubbled }) => {
})
import.meta.hot.beforeUpdate and import.meta.hot.afterUpdate
Those are global hooks that are called when a HMR update to any module, before the first dispose
handler, and after the last accept
handler.
They can be useful to implement some HMR enhancements. For example, you could save the scroll position before the update and restore it after the update. This is not specific to a particular module, yet it could be influenced by any module update, and the scroll is a singleton resource. So this should be implemented in a central location, which can be done with these hooks.
if (import.meta.hot) {
let scrollTopBefore = null
import.meta.hot.beforeUpdate(() => {
scrollTopBefore = document.body.scrollTop
})
import.meta.hot.afterUpdate(() => {
requestAnimationFrame(() => {
document.body.scrollTop = scrollTopBefore
})
})
}
License
ISC