May 1, 2023

Hot reloading content in Craft CMS's Live Preview

When I saw Jack Sleight's post about hot reloading in Statamic's live preview I was pretty blown away. Live preview is already incredibly useful in a CMS, but inserting content changes without a refresh would be a huge upgrade to that experience.

A typical live preview experience works like this:

  1. A companion <iframe> is loaded, showing you the draft of your current entry
  2. When a content change is made in the CMS the <iframe> is refreshed, showing the latest changes

Statamic 3.4.8 introduced the ability to disable the auto-refresh in favor of sending a postMessage() event via JavaScript. That means, the live preview experience changes a bit:

  1. A companion <iframe> is loaded, showing you the draft of your current entry
  2. When a content change is made the CMS doesn't refresh the <iframe>, but instead broadcasts a postMessage() event your site can listen to and act upon.

Once I saw this, I wanted to see if I could acheive a similar experience in Craft.

Setting it up in Craft

⚠️ My example uses Alpine.js and the (excellent) Morph plugin to perform the DOM diff and update for the content swap. However, you can substitute this with another DOM morphing library of your choice.
  1. We need to disable auto-refresh on any Preview Targets within our entry types. We no longer want the <iframe> to get a full-page reload.
  2. Now need to register some JavaScript in the control panel that will fire our postMessage() event when the content has changed. I'm not going to walk through this process, but this is where you would likely create a Craft CMS module and, on control panel requests, insert the postMessage() JavaScript code:
// Use Garnish to hook into Craft's beforeUpdateIframe's event
Garnish.on(Craft.Preview, 'beforeUpdateIframe', function (event) {
  if (!event.refresh) {
    // Once the content has been changed, fire a postMessage event with a live preview key,
    // but scope the broadcast to our site for security purposes
    event.target.$iframe[0].contentWindow.postMessage(
      'entry:live-preview:updated',
      event.previewTarget.url
    )
  }
})
  1. In your front-end template—likely your _layout.twig file—we need to add the following to act upon the broadcasted postMessage()
{# Execute this JavaScript in preview mode only #}
{% if craft.app.request.isPreview %}
  {# Include Alpine.js and the Morph plugin #}
  <!-- Alpine Plugins -->
  <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>

  <!-- Alpine Core -->
  <script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

  <script>
    {# Listen for postMessage() calls #}
    window.addEventListener('message', async (event) => {
      {# Ignore messages that don't use our live preview key #}
      if (event.data !== 'entry:live-preview:updated') {
        return
      }

      {# Grab the URL we need to process from the event, send it to a fetch() call, and then parse the text #}
      const text = await fetch(event.target.location.href).then((res) => res.text())

      {# Process the text as HTML #}
      const updated = new DOMParser().parseFromString(text, 'text/html')

      {# Use Alpine.js's morph plugin to diff the existing DOM to the new HTML and update the page #}
      Alpine.morph(document.body, updated.body)
    })
  </script>
{% endif %}

And now, once you've opened your Live Preview panel in Craft CMS you'll be able to make changes without incurring a full-page refresh!

Dynamically updating content in Craft CMS's Live Preview

After a bit of setup it's an impressive experience! And, like hot module reloading, it's hard to go back once you've used it.