Hot Module Reloading for Content in Sveltekit

Written

I recently converted my website from Sapper to SvelteKit. SvelteKit uses the Vite build tool, and one of Vite’s great features is Hot Module Reloading, or HMR, which can reload changed parts of a site without reloading the entire browser page.

This is great for speeding up developer productivity, and so I wanted to see if I could support HMR when changing the Markdown and HTML files that make up the content of this site as well. Between Vite’s HMR API and some SvelteKit features, it was easier than I expected.

Vite HMR Plugin 🔗

The first step is to add a build plugin into the Vite configuration. With SvelteKit, this configuration goes inside your svelte.config.js.

Vite plugins have a similar API to Rollup plugins, but with some extra methods. The plugins provide one or more hooks into the build process, and then Vite calls those hooks at the appropriate time.

First, our plugin provides a configureServer hook which tells Vite to watch the content directories for changes. In Rollup this would be done in the buildStart hook and call this.watch to add the paths, but that doesn’t work for Vite dev mode. Instead, you call server.watcher.add(path). In the snippet below, you can see I add three directories to the watcher.

Adding these directories tells Vite to monitor them for changes and have them participate in the HMR process. When a change occurs, Vite will call the handleHotUpdate hook with one argument of type HmrContext.

interface HmrContext {
  file: string;
  timestamp: number;
  modules: Array<ModuleNode>;
  read: () => string | Promise<string>;
  server: ViteDevServer;
}

The full documentation explains it in more detail, but you can then return modules, filter down the list of modules to reload fewer modules, or send a custom event to the client side using server.ws.send. In our case, we look for a path matching one of the content directories, and send a custom client event. The full plugin is below.

/** @type {import('@sveltejs/kit').Config} */
export default {
  kit: {
    vite: () => {
      plugins: [
        {
          name: 'watch-content',
          configureServer(server) {
            server.watcher.add(path.join(dirname, 'posts'));
            server.watcher.add(path.join(dirname, 'notes'));
            server.watcher.add(path.join(dirname, 'pkm-pages'));
          },
          handleHotUpdate(ctx) {
            let m = /(notes|posts|pkm-pages)\/(.*)\.(md|html)$/.exec(ctx.file);
            if (m) {
              let contentType = m[1];
              let id = m[2];

              // This is just a conversion from the directory
              // names to the URLs used in the site.
              if (contentType === 'pkm-pages') {
                contentType = 'notes';
              } else if (contentType === 'posts') {
                contentType = 'writing';
              }

              ctx.server.ws.send({
                type: 'custom',
                event: 'content-update',
                data: {
                  type: contentType,
                  id,
                },
              });

              // Return an empty module list since we
              // handled it manually.
              return [];
            }

            // Not an event we care about, so just do
            // the default behavior.
            return ctx.modules;
          },
        },
      ];
    },
  },
};

The Client Side 🔗

Once we can send events to the client, we need to handle them and actually perform the reload. Happily, SvelteKit makes this easy to do. Each article page uses a SvelteKit load function to fetch the content, and SvelteKit also lets you force these load functions to rerun using the invalidate function.

Vite’s HMR API is fairly complex, but for our case of just listening to a custom event, it’s straightforward. We check for the presence of false to see if Vite is running, and if so it’s just a matter of adding the event listener for the custom content-update event.

<script>
  import { invalidate } from '$app/navigation';

  if (false) {
    false.on('content-update', (data) => {
      if (data.type === 'notes') {
        invalidate('/notes/list.json');
        invalidate('/notes/tags.json');
        invalidate(`/notes/note/${data.id}.json`);
      } else if (data.type === 'writing') {
        invalidate('/writing/list.json');
        invalidate(`/writing/${data.id}.json`);
      }
    });
  }
</script>

Here I just placed a single event listener at the root __layout.svelte file of the site, which examines the event and invalidates all the relevant endpoints. Larger sites would probably want to break this out to have each component manage its own HMR event handling, but the concept is the same.

SvelteKit internally tracks the URLs fetched by each load function, and to invalidate then compares its argument to the tracked URLs, and reruns any load functions on active pages or layout components that match. And that’s it!


Thanks for reading! If you have any questions or comments, please send me a note on Twitter.