zodern

Updating Meteor Package for HMR

With Meteor 2 being released this month, we now have hot module replacement (HMR) in Meteor apps. This first version is not able to update modules within Meteor packages (it is definitely planned though), but our Meteor packages can affect the experience with using HMR with Meteor apps.

If you want more information about HMR, and Meteor's implementation of it, you can look at:

A more detailed description of how HMR works is available in the Meteor docs, but the details relevant to this post are:

  1. Meteor re-runs modules when applying updates, either because the module was modified, or one of its dependencies were modified. Until the old module gets garbage collected, there are multiple instances of it running within the app.
  2. Before re-running each module, Meteor calls any dispose callbacks the module had registered. These dispose callbacks take care of making sure the old instance of the module no longer affects the running app. For example, it can stop Tracker computations, remove event listeners, clear timers, etc.

Before HMR, Meteor would always reload the client if it was modified. We didn't always need to worry about adding a way to remove event listeners or hooks, or provide a way to undo other things an app could configure with our package. If a developer changed a hook registered with our package or how it configured our package, the page would reload, and the old version would be gone. With HMR, it isn't that simple. In case one of the modules that are re-run uses our package, we should make sure our packages provide the necessary api's so the module can be disposed properly.

For an example, we will work with an imagined package named extensive-error-reporting. This package hooks into various parts of Meteor to give us the errors Meteor normally only logs to the console.

The package's code might look like:


let errorListeners = [];

function init() {
// Here the package would hook into Meteor to
// receive the errors. When there is an error,
// it will call all of the functions in errorListeners
}

init();

export const onError = (listener) => {
errorListeners.push(listener);
};

And the package could be used like:

import { onError } from 'meteor/extensive-error-reporting';

onError(err => {
console.log('There was an error!', err);
});

If updates to this module get accepted (maybe React Fast Refresh accepts updates for one of its parents), anytime it is re-ran it will add an extra listener and an extra console.log message. If you change what the listener does, such as sending the error to an error tracking service instead of logging it, the old listeners will still log the error.

If you understand that this is caused by HMR and there being multiple instances of the module running, you might reload the page to get back to there being one instance of the module. You might next add some code to prevent this module from being re-ran (Meteor will reload the page instead) to prevent this from happening again (it can get annoying if it happens too often):

if (module.hot) {
module.hot.decline();
}

But by making a small change to the package, we could allow the module to still be updated with HMR. onError could return a function that removes the listener:


let errorListeners = [];

function init() {
// Here the package would hook into Meteor to
// receive the errors. When there is an error,
// it will call all of the functions in errorListeners
}

init();

export const onError = (listener) => {
errorListeners.push(listener);

return () => {
const index = errorListeners.indexOf(listener);

if (index > -1) {
errorListeners.splice(index, 1);
}
};
};

Then the module in our app can be updated to call the returned function when the module is disposed:

import { onError } from 'meteor/extensive-error-reporting';

const removeListener = onError(err => {
console.log('There was an error!', err);
});

if (module.hot) {
module.hot.dispose(() => removeListener());
}

Now, every time this module is re-ran, there will still be one error listener, and it will always be from the newest version of the module.

It would be nice if the app's developer wouldn't have to think about and write the code for the dispose callback. If onError is usually called in the top-level of the module, our package can record which error listeners were added by each module, and automatically add dispose callbacks. This can be done with module.hot.onRequire. We will have a variable with the id of the currently running module, and add hooks to run before and after each module is imported to update the variable. It will record which event listeners were added by the current module, and add a dispose callback to remove them. The code would look like:

let errorListeners = [];

// This has the id of the current module. For example:
// /imports/client/app.jsx
let currentModule = null;
let addedBy = new WeakMap();

if (module.hot) {
module.hot.onRequire({
before(module, parentModuleId) {
currentModule = module.id;

if (module.hot) {
// When the module is disposed, remove all error listeners
// it added
module.hot.dispose(() => {
for (let i = errorListeners.length - 1; i >= 0; i--) {
if (addedBy.get(errorListeners[i]) === module.id) {
errorListeners.splice(i, 1);
}
}
});
}

// Return the parent id so after this module
// finishes running, the after hook can update
// currentModule
return { parentModuleId };
},
after(module, beforeData) {
currentModule = beforeData.parentModuleId;
}
});
}

function init() {
// Here the package would hook into Meteor to
// receive the errors. When there is an error,
// it will call all of the functions in errorListeners
}

init();

export const onError = (listener) => {
if (listener) {
addedBy.set(listener, currentModule);
}

errorListeners.push(listener);

return () => {
const index = errorListeners.indexOf(listener);

if (index > -1) {
errorListeners.splice(index, 1);
}
};
};

Now, we can change the file in the app back to how it was originally, and the error listener will still be removed when the old module instance is disposed.

import { onError } from 'meteor/extensive-error-reporting';

const removeListener = onError(err => {
console.log('There was an error!', err);
});

This added a lot of extra complexity and code to our package. Is it worth it? I decided it was for zodern:pure-admin, but for many packages simply providing a way for the app to handle disposing itself is enough, or the package might not need to worry about this at all. Some things you might want to consider:

  1. Will the package be used in files for the view layer, or in other files imported by those files for the view layer? If so, changes to those files will likely be applied with HMR, and for a good experience our package should support it.
  2. Will developers want to rapidly iterate on the code that uses our package? If they do and our package is compatible with HMR, they could add module.hot.accept() to files that use our package so they are updated with HMR.

The module.hot.onRequire hooks can be used for more than just helping to dispose modules. The blaze-hot (used to provide HMR for Blaze) and react-fast-refresh Meteor packages also use it to detect modules that can safely call module.hot.accept(). In addition to telling Meteor which files can be updated with HMR, module.hot.accept() also tells Meteor how many modules need to be re-ran to use the new exports from the modified module. Meteor re-runs the modules that were modified, the modules that imported those, the modules that imported those, and so on until it gets to the modules that accepted the update. Let's look at a simple example of where this can be used.

Let's use another imagined package: simple-i18n. This package is for internationalization of apps. We give it an object for each language where the key is a message id, and the value is the message in that language. The package might also provide methods to change the current language, and to get the message for a specific id in the current language, but we are going to ignore those since they are not important to this example.

import { registerMessages } from 'meteor/simple-i18n';

registerMessages('en', {
welcome: 'Welcome',
goodbye: 'Goodbye'
});

registerMessages('es', {
welcome: 'Bienvenido',
goodbye: 'AdiĆ³s'
});

registerMessages('fr', {
welcome: 'Bienvenue',
goodbye: 'Au revoir'
});

This file will be frequently changed as we add messages for new features. We could simply add some code to the file so it is updated with HMR, and document this in the package's documentation.


if (module.hot) {
module.hot.accept();
}

This is probably a good scenario though where the package could automatically accept updates to this module. To identify modules that can be updated with HMR, we will use these two requirements:

  1. The module has no exports. If it does have exports, we want the modules that imported it to be re-run to use the new exports, and accepting updates in this module would prevent that.
  2. The module calls registerMessages.

The code for this in the package would look like:

let registerMessagesCalled = false;

if (module.hot) {
module.hot.onRequire({
before(module, parentModuleId) {
let parentCalled = registerMessagesCalled;

registerMessagesCalled = false;

// This will be available in the after hook as beforeData.
return { parentCalled };
},
after(module, beforeData) {
// Make sure HMR is available,
// the module has no exports,
// and the module added messages
if (module.hot && registerMessagesCalled && !module.exports) {
module.hot.accept();
}

registerMessagesCalled = beforeData.parentCalled;
}
});
}


export function registerMessages(language, messages) {
registerMessagesCalled = true;

// Here it would store the messages
}

Now, any change to our file with the messages will be automatically applied with HMR.

Some projects put the messages in a Meteor package so they can be shared between multiple apps. Would this work for those projects? At this time, no, because HMR isn't able to update files within packages. However, once Meteor is able to, the integration we created would work with packages with no modification.

The types of files that this can be done for is small. Many files do things that need special dispose handlers, or there is no good way to detect that they can be safely updated with HMR. But when this can be done in a way that works reliably, it greatly improves the developer experience, as can be experienced when modifying React components in a Meteor app with HMR.

To recap:

We already have at least 5 packages providing integrations with HMR for different view layers, showing build errors on the client, and applying changes to an admin dashboard. What else can packages do to improve the developer experience when using HMR? I am excited to see what you come up with!

Have a comment? Please Send me an email


Want More? Join my monthly newsletter for new blog posts and news about Meteor and my projects.

Have a Meteor app in production? I run Monti APM for production monitoring to help improve performance and reduce errors.