Home » Blog » Engineering » Hot Module Replacement for Gutenberg Blocks
Share on

Hot Module Replacement for Gutenberg Blocks

In tandem with this effort, we have explored how to make Gutenberg a first-class citizen within our development process and developer tooling.

At Human Made we’re proud to have been at the forefront of our community’s transition towards tools like React, and we have previously released several projects that make it easier to use React within WordPress.

Many of those same techniques we use for React can benefit our Gutenberg development, and applying these tools to WordPress makes all the difference on a project when some developers have a stronger background in React than in WordPress, or vice versa. The quality of our developer experience becomes a key performance indicator, influencing our processes in a range of areas: from hiring all the way to delivering code that will stand the test of time in a rapidly changing industry.

In this post, I will share some of the techniques we are using in our projects to bring familiar React-ecosystem developer conveniences to our Gutenberg work.

Reduce Block Registration Boilerplate

The most straightforward way to register a custom editor block is to call the registerBlockType method within a JavaScript module defining our custom block:

// src/blocks/book.js
registerBlockType( 'my-plugin/book', { /* type definition */ };

This approach is unambiguous, but by including the registerBlockType call inline within this module we duplicate the block registration boilerplate in every block file we create, and we lose the opportunity to test any of this module’s behavior independently of the core WordPress block registry. Because of this we now follow the pattern where a block module will export the block’s name and settings object, then we then call registerBlockType from the parent level:

// src/blocks/book.js
export const name = 'my-plugin/book';
export const settings = { /* type definition */ };
// src/blocks.js
import * as bookBlock from './blocks/book';
import * as albumBlock from './blocks/album';
[
    bookBlock,
    albumBlock,
].forEach( ( { name, settings } ) => registerBlockType( name, settings ) );

Any custom logic or components in the block modules can now be imported and tested without side effects, and our block modules are free of boilerplate. However, we still need to manually enumerate each block we wish to include in our bundle—this is sometimes useful, but it can also lead to bugs or build errors if a developer forgets to include the block module.

If our project conforms to a consistent directory structure such as defining each block in an index file within the blocks folder (e.g. src/blocks/{ block name }/index.js), we can further simplify this process by using Webpack’s require.context to automatically detect and load each block. The require.context function lets us provide a directory to search and a regular expression pattern to govern which files are included, so we can auto-load all index.js files within the blocks/ directory:

// src/blocks.js

// Create a require context containing all matched block files.
const context = require.context(
    './blocks',  // Search within the src/blocks directory
    true,        // Search recursively
    /index\.js$/ // Match any file named index.js
);

context.keys().forEach( modulePath => {
    // context() is a function which includes and returns the module.
    const block = context( modulePath );
    registerBlockType( block.name, block.settings );
} );

This is less obvious than import { name, settings } from blocks/block-name.js, but now we can add or remove blocks at will without writing a single include or registerBlockType statement. Cutting out that type of overhead makes a big difference on a large project.

Introduce Hot Module Replacement

Of all the benefits we gain by building applications with React and Webpack, Hot Module Replacement (HMR for short) is one of the most impressive. When properly configured HMR allows developers to make changes to a component file, save, and see that component update instantly on our webpage without disrupting any other application state, dramatically speeding up prototyping work and code iteration.

Gutenberg-Icon

There’s a host of tools to support HMR in traditional React or Vue applications, but it’s not as obvious how to apply those concepts to Gutenberg blocks. What would it take to make changes to our components’ edit() and render() methods take effect in front of our eyes, without a page load or editor refresh?

The process we want to follow looks something like this:

  1. Detect any changes to a block’s JavaScript file(s).
  2. Unload the previous version of the block (Gutenberg displays an error if you try to register two blocks with the same name).
  3. Load the updated block module.
  4. Register the updated block.

HMR is normally configured by detecting the module.hot object (only present in development builds) and calling module.hot.accept() within the module to be swapped. This can add a lot of boilerplate to our block files, though, and as discussed above that’s a result we want to avoid; additionally, our use of require.context makes the use of module.hot.accept a little less intuitive.

From Webpack’s point of view, the tree of all auto-loaded blocks is considered to be one entity. If we want to intelligently reload only a part of that module tree, we need to apply our own logic to identify what in that tree has changed. This, therefore, is the process we need to follow to add HMR to our block files:

  1. When loading blocks with require.context, save a reference to the block module in a local cache object.
  2. Tell Webpack to module.hot.accept the entire require.context tree.
  3. When an update is detected within that tree, check each module against the cache to see which specific blocks have changed.
  4. Only unregister & re-register the individual blocks which have been updated.

Putting that together, we get this:

// Use a simple object as our module cache.
const cache = {};

// Define the logic for loading and swapping modules.
const loadModules = () => {
    // Create a new require.context on each HMR update; they cannot be re-used.
    const context = require.context( './blocks', true, /index\.js$/ );

    // Contextually load, reload or skip each block.
    context.keys().forEach( key => {
        const module = context( key );
        if ( module === cache[ key ] ) {
            // require.context helpfully returns the same object reference for
            // unchanged modules. Comparing modules with strict equality lets us
            // pick out only the edited blocks which require re-registration.
            return;
        }
        if ( cache[ key ] ) {
            // Module changed, and prior copy detected: unregister old module.
            const oldModule = cache[ key ];
            unregisterBlockType( oldModule.name );
        }
        // Register new module and update cache.
        registerBlockType( module.name, module.settings );
        cache[ key ] = module;
    } );

    // Return the context so we can access its ID later.
    return context;
};

// Trigger the initial module load and store a reference to the context
// so we can access the context's ID property.
const context = loadModules();

if ( module.hot ) {
    // In a hot-reloading environment, accept hot updates for that context ID.
    // Reload and compare the full tree on any child module change, but only
    // swap out changed modules (using the logic above).
    module.hot.accept( context.id, loadModules );
}

Seeing Your Changes

With the above code, our custom editor blocks will be swapped out in the background whenever we make changes. But those changes won’t be reflected in the editor until we select the updated blocks, and worse, if we update code for a block which is selected in the editor we may see that “The editor has encountered an unexpected error.” There are a couple final things we need to do to make this process seamless.

First, we will dispatch a core editor action to deselect the current editor block before we swap out the modules. We don’t want to lose state either, though, so we will save the clientId of the currently-selected editor block. We add this code immediately prior to the require.context call in the above example:

// Save the currently selected block's clientId.
const selectedBlockId = select( 'core/editor' ).getSelectedBlockClientId();
// Clear selection before swapping out upated modules.
dispatch( 'core/editor' ).clearSelectedBlock();

Then, immediately following the .forEach() loop we restore that selection once our updates are complete:

// Restore the initial block selection.
if ( selectedBlockId ) {
    dispatch( 'core/editor' ).selectBlock( selectedBlockId );
}

That takes care of the “unexpected error” described above.

Second, ideally we would be able to see our changes take effect in real-time. However Gutenberg has no inherent knowledge of our HMR updates, and re-registering a block is not sufficient to force a UI update. We can make all the changes we like, but won’t see any updates in the browser until we next select the block and force a re-render.

To work around this we can loop through all the editor’s current blocks and select each one, prompting Gutenberg to re-render each block in turn. We add this logic right between the .forEach loop and the snippet above that restores the prior selection:

select( 'core/editor' ).getBlocks().forEach( ( { clientId } ) => {
    dispatch( 'core/editor' ).selectBlock( clientId );
} );

(This approach will also select blocks which have not changed, but it is the most reliable way we have found to guarantee updated blocks get re-rendered. The potential performance gains are insufficient to justify the added complexity of more intelligently targeting our block selection actions.)

Our full update flow now looks like this:

  1. When loading blocks with require.context, save a reference to the block module in a local cache object.
  2. Tell Webpack to module.hot.accept the entire require.context tree.
  3. When an update is detected within that tree,
    1. Save a reference to the currently-selected block within the editor.
    2. Deselect all blocks to avoid issues while swapping block code.
    3. Loop through each module and check against the cache to see which specific blocks have changed.
    4. Unregister & re-register the individual blocks which have been updated.
  4. After all blocks are updated,
    1. Loop through each block in the editor and trigger a select action on each to refresh rendered content.
    2. Reselect whichever block was selected at the start of the update.

This flow registers all blocks, intelligently re-registers and hot-swaps updated blocks, and ensures that any updates get reflected in the editor as they are made! We can now rapidly iterate on our block code and observe the changes right in the block editor, achieving all the benefits of hot module reloading in a WordPress-specific context.

Going Further

This same approach here can be used to load and reload Gutenberg editor plugins, and we anticipate releasing an internal tool in the near future which abstracts this logic into a reusable module. Until then, a complete example of the technique described above can be found on GitHub.

We should note that all of the techniques discussed in this post depend upon the Webpack development server, which deserves a blog post in and of itself! The linked repository contains a sample bare-bones Webpack configuration adapted from the concepts behind react-wp-scripts, but stay tuned for further posts here on our developer tooling over the coming months.

In closing, I would like to thank my colleagues for their support and creativity over the past year of learning and growth with Gutenberg, especially Dzikri Aziz, Than Taintor, and Joe McGill, without whom this post would not exist. It is a true privilege to work with a team so dedicated to improving the state of our art.