Une chose que j’apprécie particulièrement dans l'écosystème JS moderne (React/Vue), c’est l'expérience développeur (DX) confortable d’une boucle de feedback instantanée offerte par le Hot Module Replacement (HMR). Cependant, dans un environnement Server-Side Rendering (SSR) comme Drupal (PHP/Twig), la chaîne est rompue: le serveur PHP génère du HTML opaque pour Vite et le HMR n'en tient pas compte.
S’il est relativement simple de mettre en place le développement d’un thème Drupal avec Vite (voir TailwindCSS), incluant le HMR pour le CSS/JS, cela reste incontestablement peu commun pour le HTML lui-même. C’est pour répondre à cette problématique que j’ai conçu le plugin Vite vite-plugin-drupal-hmr.

L'outil couvre les templates Twig, les Single-Directory Components (SDC), le CSS et le JS.
Description du problème
Le défi pour la résolution HMR du HTML réside dans la séparation des responsabilités. D'un côté Vite (Node.js) gère les assets et le HMR via un canal WebSocket dédié. Il nécessite l'inclusion de l'URL /@vite/client dans le header du site. De l'autre Drupal (PHP/Twig) gère la logique métier de l’application et la construction du markup HTML.
Contrairement au composants React ou Vue écrit en JS, Vite n'a aucune notion ni des fichier Twig, ni des processus PHP qui conduisent à leur transformation en HTML. Il faut donc à la fois:
- lui dire de les prendre en compte
- informer le navigateur qu'un template spécifique a changé
- lui expliquer quoi faire de cette info et quoi changer sur la page
Ce sont ces trois étapes clés que prend en charge le module vite-plugin-drupal-hmr.
Hooking dans le cycle de vie de Vite
L'implémentation repose sur l'exploitation de l'API de Plugin de Vite pour injecter un runtime personnalisé, c'est-à-dire du code réagissant aux événements HMR Vite, côté frontend sur le site dans le navigateur.
- Client Code Injection (
transform):
Via le hooktransform, nous injectons dynamiquement l'import d’un module virtuelimport '${VIRTUAL_NAME}';\n${code}
en prélude du bundle JS principal servi au navigateur. Cela garantit que chaque page chargée dispose du runtime d'écoute WebSocket et que le développeur n’a pas à gérer l’inclusion de fichier JS spécifique, seulement ajouter la WebSocket/@vite/clientà son site. - Virtual Module Injection (
resolveId):
L’import module virtuelvirtual:drupal-hmrinjecté plus tôt est ensuite résolu directement par Vite via le hookresolveId. Il retourne le code contenu dans le fichierhmr.jsdu plugin et contenant le client HMR spécifique à notre contexte. - Orchestration des WebSockets (
handleHotUpdate):
Côté Vite, tout changement de fichier est détecté et transmis au hookhandleHotUpdate. Par défaut, Vite ne sait pas comment traiter un fichier.twiget l'évite donc. Notre module modifie cela.- Comme les fichiers Twig sont traités par Drupal, nous devons recomposer un chemin non pas relatif à Vite, mais au root Drupal. Le plugin est capable de le faire automatiquement pour un setup classique, en naviguant récursivement les répertoires parents du projet. Mais il se peut aussi que vous fassiez tourner Vite dans un conteneur Docker spécialisé n’ayant pas accès au reste de l’application. C’est pourquoi les paramètres optionnels themeName et themePath permettent de surcharger cette détection.
- Nous devons également déterminer si le type de fichier Twig (Template vs SDC Component) en s'appuyant sur les conventions des noms de dossier Drupal.
- Une fois toutes ces infos obtenues, elles sont envoyées au client via le canal websocket existant
/@vite/clientdans le payload d’un événement customcustom:twig-update.
Réconciliation du DOM (Client-Side)
À la réception de l'événement websocket, le navigateur connaît maintenant le nom, le path et le type du fichier twig modifié. Il lui faut:
- Fetcher: Le client effectue une requête
fetchde l'URL courante en arrière-plan. - Parser: Le
TreeWalkerparcourt le DOM actuel et le nouveau DOM pour isoler le fragment HTML modifié. - Patcher: L'API
Rangeest utilisée pour supprimer précisément les nœuds entre les commentaires et injecter le nouveauDocumentFragment.
C’est là qu’intervient une solution que je qualifierais de “sale mais maline”, ou comme dirait tout bon auteur LinkedIn: “une stratégie heuristique robuste face à un output non-déterministe”, c'est-à-dire une regexp dans du HTML !
Identification des fragments non-déterministe
Pour localiser un template spécifique dans le DOM, nous nous appuyons sur le mode debug de Twig qui entoure le rendu de commentaires HTML. Cependant, Drupal introduit une difficulté inattendue : les commentaires contiennent un emoji aléatoire. Le parser côté client (src/hmr.ts) doit donc utiliser des expressions régulières pour identifier ces fragments HTML malgré le bruit de l’emoji.// src/hmr.ts // Utilisation de \p{Emoji} pour gérer les caractères Unicode non-déterministes const output = { begin: `<!-- \\p{Emoji} BEGIN CUSTOM TEMPLATE OUTPUT from '${ctx.templateId}' -->`, end: `<!-- END CUSTOM TEMPLATE OUTPUT from '${ctx.templateId}' -->`, }; // Extraction résiliente du contenu const regexp = new RegExp(`${output.begin}(.*?)${output.end}`, "gmsu");Manipulation du DOM via TreeWalker
Une seconde difficulté intervient dans l'écriture d’un parser capable d’identifier les bornes des fragments HTML et de les remplacer tout en gardant les commentaires, à la fois ce qui les entourent et ceux qu'ils contiennent. La raison est simple: si nous modifions un "gros" template, disonspage.html.twig. Il contient l'usage de nombreux autres templates que nous pourrions remplacer plus tard. Nous avons besoin de garder leurs commentaires. Or, un parsing ou manipulation de DOM classique via lesNodeListetHTMLElementne prend pas en compte ces commentaires. Il nous faut une API plus bas niveau. J’utilise doncTreeWalker, une API native du navigateur plus, à la fois bas niveau et plus performante qu’un parcours deNodeList.Évidemment, le template peut-être utilisé plusieurs fois par page, donc les commentaires d'origine doivent être conservés lors du remplacement pour conserver l’unicité de l’emoji, c’est le but du
Rangequi identifie les bornes du fragment en cours.- Réactivation des JS Behaviors
Le remplacement d'un fragment HTML par l'APIRangea un effet de bord majeur : la perte des event listeners attachés aux anciens éléments. Dans l'écosystème Drupal, la solution est déjà standardisée par la syntaxe des JS dans les Drupal libraries. Pour cette raison, le runtime du plugin appelle systématiquementDrupal.attachBehaviors()sur le nouveau fragment injecté. Cela permet de ré-exécuter le JavaScript lié aux composants (ex: un menu accordéon ou une modale) sans recharger la page, garantissant que le nouveau HTML soit immédiatement interactif. Un évènement customdrupal-hmr:updatedest également déclenché, permettant éventuellement à un développeur de réattacher du JS spécifique hors Drupal.
Gestion des effets de bord (CSS & Tailwind)
Dans un contexte de theming Tailwind, la modification d'un template Twig peut impliquer l'utilisation de classes utilitaires sur lesquelles Tailwind (ou autre système équivalent) va s'appuyer pour générer du CSS via un processus de build dans Vite. Notre hot-reload ne doit donc pas seulement mettre à jour le HTML, mais aussi déclencher la recompilation JIT de Tailwind puis le HMR du CSS associé. Le plugin doit donc s'assurer que le graphe de dépendance de Vite invalide également le CSS généré et que l’envoi de l'événement de HMR twig dans la websocket se fasse en plus et non en remplacement du processus des autres plugins Vite.
Trade-off et décisions architecturales
Pour ce projet, je voulais impérativement un DX simple, le plus zero-config possible. En prenant pour présupposé que vous avez déjà un setup DDEV + Drupal 11 + Vite 7 + TailWind 4 qui fonctionne, ce qui n’est pas trivial, alors y ajouter le HMR Twig doit être une formalité.
J'ai pensé un temps développer un module Drupal compagnon pour injecter dans les templates un data-id unique afin de les identifier en se passant de l’appui des commentaires, mais cela ne fonctionne que pour des templates avec un node root unique (contrainte qui existait aussi sur Vue 2 d’ailleurs). Cette idée aurait donc été moins fiable et générique.
On peut également imaginer la mise en place d’une API Rest Drupal renvoyant uniquement le rendu du template modifié. Cela permettrait de se passer d’un re-fetch complet de la page en background ce qui est intrinsèquement lourd. Cela aurait été un gros boulot, aurait ajouté des points d'entrée dans Drupal, des vecteurs possibles d'attaque à sécuriser et du code en production. Comme nous cherchons ici à résoudre un problème en développement, je cherchais à éviter cela. Ce module introduit un process asynchrone et non bloquant, en mode debug dans un environnement local uniquement: c’est un compromis acceptable pour favoriser le DX, la simplicité du code et donc de la maintenance de ce système.
Enfin, l'ajout de la réexécution automatique des Drupal.behaviors comble également le fossé entre le simple remplacement de texte et un véritable HMR fonctionnel, en restaurant l'interactivité des éléments remplacés. Toutefois, je n'ai pas trouvé pour l'instant une solution satisfaisante pour la conservation de l'état spécifique du DOM dans le fragment, ce que l'on pourrait qualifier de Stateful HMR.
Toutes ces idées restent valables et pourraient être mises en place via des configurations optionnelles complémentaires. Des développements que je n'envisagerai que pragmatiquement en fonction de l'usage réel de cet outil.
En conclusion
Ce projet démontre qu'il est possible de moderniser la DX d'environnements SSR en conjuguant les différents frameworks et paradigmes. Pour ma part, il m’a permis de jouer avec les mécanismes internes des bundlers modernes (AST, HMR API) et de manipulation de DOM à bas niveau via TreeWalker. Si au passage cela peut combler le fossé entre PHP et l'écosystème Vite actuel, tout en vous étant utile, alors sachez que le module est disponible sur NPM et le code source sur GitHub.
Ajouter un commentaire