Vite HMR and PHP SSR: Instant HTML Fragment Reloading

One thing I particularly appreciate in the modern JS ecosystem (React/Vue) is the comfortable Developer Experience (DX) of the instant feedback loop offered by Hot Module Replacement (HMR). However, in a Server-Side Rendering (SSR) environment like Drupal (PHP/Twig), the chain is broken: the PHP server generates an HTML that is "opaque" to Vite so the HMR simply ignores it.

While it is relatively simple to set up a development environment for Drupal theming using Vite (eventually TailwindCSS), including HMR for CSS and JS, it remains undeniably uncommon to have HMR for the HTML from Twig templates themselves. To address this specific challenge, I wrote the Vite plugin vite-plugin-drupal-hmr.

This article is not a user tutorial (please refer to the project's readme) but rather an analysis of the engineering required to synchronize a PHP server state with a Vite dev-server.

Drupal theming with Tailwind CSS via Vite and realtime integration via HMR.
The tool covers the Twig templates, the Single-Directory Components (SDC), the CSS and JS.

Problem description

The challenge for HMR resolution of HTML lies in the separation of responsibilities. On one side, Vite (Node.js) manages assets and HMR via a dedicated WebSocket channel: /@vite/client. On the other side, Drupal (PHP/Twig) handles the application's business logic and the construction of the HTML markup.

Unlike React or Vue components written in JS, Vite has no concept of Twig files or of the PHP processes that transform them into HTML. Therefore, we must simultaneously:

  • Tell Vite to track those files.
  • Inform the browser that a specific template has changed.
  • Explain the client what to do with this information and what needs to change on the page.

These are the three key steps handled by the vite-plugin-drupal-hmr module.

Hooking into the Vite Lifecycle

The implementation relies on leveraging the Vite Plugin API to inject a custom runtime: some code that reacts to Vite HMR events sent to the frontend, directly into the browser.

  • Client Code Injection (transform):
    Via the transform hook, we dynamically inject a virtual module import:
    import '${VIRTUAL_NAME}';\n${code}
    This is prepended to the main JS bundle served to the browser. It ensures that every loaded page has the WebSocket listener runtime, meaning the developers don't have to manually manage specific JS file inclusions. They only need to add the /@vite/client WebSocket to their site.
  • Virtual Module Injection (resolveId):
    The virtual:drupal-hmr module import injected earlier is resolved directly by Vite via the resolveId hook. It loads the code inside the plugin's hmr.js file, which contains the specific HMR client for our context.
  • WebSockets orchestration (handleHotUpdate):
    On the Vite side, any file change is detected and passed to the handleHotUpdate hook. By default, Vite doesn't know how to handle a .twig file and therefore ignores it. Our module changes that.
    • Path Mapping: Since Twig files are processed by Drupal, we must reconstruct a path relative to the Drupal root rather than Vite project root. The plugin handles this automatically for standard setups by recursively navigating parent directories.
    • Docker Support: If you run Vite in a specialized Docker container without access to the rest of the application, you can use the themeName and themePath parameters to override this detection.
    • Component Detection: We also determine the Twig file type (Template vs. SDC Component) based on Drupal's folder naming conventions.
    • Fire event: Once this information is gathered, it is sent to the client via the existing /@vite/client WebSocket channel within the payload of a custom event: custom:twig-update.

Custom Twig event in Chrome Dev Console
Viewing the custom:twig-update event in the Vite WebSocket via Chrome DevTools

DOM reconciliation (Client-Side)

Upon receiving the WebSocket event, the browser now knows the name, path, and type of the modified Twig file. It must then:

  • Fetch: The client performs a background fetch request of the current URL.
  • Parse: A TreeWalker traverses both the current and the new DOM to isolate the modified HTML fragment.
  • Patch: The Range API is used to precisely remove nodes between the comments and inject the new DocumentFragment.

This is where I implemented a solution I would call "dirty but clever" or as a LinkedIn influencer might say: "a robust heuristic strategy to face a non-deterministic output", which is a fancy way of saying I used a regex on HTML!

  • Identifying Non-Deterministic Fragments
    To locate a specific template in the DOM, we rely on Twig’s debug mode, which wraps the rendered output in HTML comments. However, Drupal adds an unexpected hurdle: the comments contain a random emoji. For this reason, the client-side parser (src/hmr.ts) must use regular expressions to identify these fragments despite the "emoji noise."

    // src/hmr.ts 
    // Using \p{Emoji} to handle non-determinist Unicode characters 
    const output = {
       begin: `<!-- \\p{Emoji} BEGIN CUSTOM TEMPLATE OUTPUT from '${ctx.templateId}' -->`,
       end: `<!-- END CUSTOM TEMPLATE OUTPUT from '${ctx.templateId}' -->`,
    };
     // Resilient content extraction
    const regexp = new RegExp(`${output.begin}(.*?)${output.end}`, "gmsu");

  • DOM Manipulation via TreeWalker
    A second challenge arises: writing a parser capable of identifying the boundaries of HTML fragments and replacing them while preserving the surrounding and nested comments. The reason is simple: if we modify a "large" template (like page.html.twig), it likely contains many other templates we might want to replace later. We need to keep their comments intact. Since standard DOM manipulation via NodeList or HTMLElement often ignores comments, we need a lower-level API. I used TreeWalker, a native browser API that is both lower-level and more performant than iterating through a NodeList.

    Obviously, a template might be used multiple times on a single page. Thus, the original comments must be preserved during the swap to maintain the uniqueness of the emoji. This is the role of the Range API, which identifies the specific boundaries of the fragment being processed.

  • Reactivating JS Behaviors
    Replacing an HTML fragment via the Range API has one major side effect: the loss of event listeners attached to the old elements. In the Drupal ecosystem, the solution is already standardized through the syntax of JS in Drupal libraries. For this reason, the plugin runtime systematically calls Drupal.attachBehaviors() on the newly injected fragment. This re-executes the JavaScript linked to components (e.g., an accordion menu or a modal) without a page reload, ensuring the new HTML is immediately interactive. A custom drupal-hmr:updated event is also fired, allowing developers to re-attach specific non-Drupal JS if needed.

Managing Side Effects (CSS & Tailwind)

In the context of Tailwind theming, modifying a Twig template may involve using utility classes that Tailwind (or an equivalent system) relies on to generate CSS via Vite’s build process. Our hot-reload must not only update the HTML but also trigger Tailwind's JIT recompilation and the HMR for the associated CSS. Therefore, the plugin must ensure that Vite’s dependency graph also invalidates the generated CSS. It must also guarantee that the Twig HMR event sent via the WebSocket acts in addition to, rather than as a replacement for, the processes of other Vite plugins.

Trade-offs and Architectural Decisions

For this project, I absolutely wanted a simple DX that was as zero-config as possible. Assuming you already have a working DDEV + Drupal 11 + Vite 7 + Tailwind 4 setup, which is none trivial, adding Twig HMR should be a mere formality.

For a while, I considered developing a companion Drupal module to inject a unique data-id into each and single templates to identify them without relying on comments. However, this would only works for templates with a single root node (a constraint that also existed in Vue 2) using the preprocessed attributes. For this reason, the idea was not reliable.

We could also imagine setting up a Drupal REST API that would return only the rendered output for the modified template. This would avoid a full background page re-fetch, which is inherently heavy. However, that would have been a lot of work and add a new Drupal entry point, potentially an attack vectors to secure and a source of data exposure to clear, as well as extra production code. Since we are solving a development-only problem here, I wanted to avoid that. This module relies on an asynchronous, non-blocking re-fetch in debug mode for local environments only: it’s an acceptable compromise to favor DX, code simplicity, and system maintainability.

Finally, adding the automatic re-execution of Drupal.behaviors bridges the gap between simple text replacement and true functional HMR by restoring interactivity to replaced elements. However, I haven't yet found a satisfying solution for preserving the specific DOM state within the fragment, aiming at what we could call Stateful HMR.

All these ideas remains valid and could later be implemented through additional optional configurations. I will only consider these developments pragmatically, based on the actual usage of this tool.

Conclusion

This project highlights how it is possible to modernize the DX of SSR environments by combining different frameworks and paradigms. For my part, it allowed me to experiment with the internal mechanisms of modern bundlers (AST, HMR API) and low-level DOM manipulation via TreeWalker. If, in the process, this can bridge the gap between PHP and the current Vite ecosystem while being useful to you, then please note that the module is available on NPM and the source code on GitHub.

Add new comment

Your name will be publicly displayed along with your comment.
Your email will be kept private and only used to notify you.
On internet, you can be who you want. Please be someone nice :)