La documentation de Turbo a la réputation d’être incomplète. Elle ne l’est pas vraiment : chaque page dit à peu près ce qu’elle doit dire. Le problème est ailleurs. Drive, Frames et Streams y sont documentés côte à côte, comme trois produits distincts, sans que rien ne dise ce qui les relie ni comment choisir. On apprend la syntaxe de chacun, et on reste bloqué sur la seule question qui compte au moment d’écrire : lequel, ici, maintenant.
Il y a une réponse, et elle est simple. Turbo ne fait qu’une chose : il intercepte une navigation, récupère du HTML, et remplace une portion du document. Drive, Frames et Streams ne sont pas trois mécanismes, ce sont trois portées de ce remplacement, qui se distinguent par une seule variable : qui désigne la cible.
Ce qui suit est ce que j’aurais voulu lire il y a trois ans. C’est long, c’est fait pour être relu par morceaux, et ça se termine par un index qui part du symptôme observable. Les versions de référence sont Turbo 8.0.23 et turbo-rails 2.0.23, toutes deux publiées le 29 janvier 2026, sur Rails 8.
Le modèle en une page
Avec Drive, la cible est implicite : c’est le <body>. Le client décide de naviguer, le serveur répond une page, Turbo échange le corps et fusionne l’en-tête.
Avec Frames, la cible est décidée par le client avant même que la requête parte. Le frame qui émet met son identifiant dans un en-tête Turbo-Frame, et Turbo n’acceptera de la réponse que l’élément portant le même identifiant. Le serveur ne choisit rien : il subit une contrainte.
Avec Streams, la cible est écrite dans la réponse. Le serveur dit remplace l'élément qui a cet id, ajoute ceci à la fin de celui-là, supprime cet autre. Il peut viser plusieurs endroits, non contigus, et il peut le faire sans que personne n’ait rien demandé, via un WebSocket.
Tout le reste découle de ça. Un frame qui ne se met pas à jour, c’est presque toujours un identifiant qui ne correspond pas. Un stream qui n’a aucun effet, c’est presque toujours une cible absente du DOM. Le morphing, qu’on verra plus loin, ne change pas ce modèle : il change seulement comment le remplacement est appliqué, pas qui le décide.
Turbo Drive
Drive est actif dès que vous chargez @hotwired/turbo. Il n’y a rien à écrire pour en profiter et, contrairement à ce que suggère l’ordre des chapitres de la documentation, c’est là que doit s’arrêter la majorité des besoins.
Ce qui est intercepté, et ce qui ne l’est pas
Drive intercepte les clics sur les <a href> de même origine et les soumissions de formulaires. Il n’intercepte pas : les liens avec un attribut target autre que _self, les liens download, les URL cross-origin, et les URL dont l’extension figure dans Turbo.config.drive.unvisitableExtensions (une cinquantaine d’extensions, dont .pdf, .zip, .csv, .jpg). Cette dernière liste est configurable et n’est documentée nulle part sur le site officiel.
Vous coupez ponctuellement avec data-turbo="false" sur l’élément ou n’importe lequel de ses ancêtres. Un data-turbo="true" imbriqué plus bas réactive.
Le cycle de vie
Deux points méritent qu’on s’y arrête, parce qu’ils expliquent une bonne moitié des bugs de cycle de vie.
L’instantané est pris de la page qu’on quitte, pas de celle qu’on charge. turbo:before-cache se déclenche sur le document vivant, juste avant que Turbo n’en fasse un cloneNode(true). C’est donc votre dernière chance de défaire ce que votre JavaScript a ajouté au DOM. Et il y a une asymétrie cruelle : le clone perd les écouteurs d’événements mais garde le DOM injecté. C’est exactement la recette de la bibliothèque initialisée deux fois au retour arrière, qu’on verra plus loin.
Détail utile : le clone remet aussi la sélection des <select> (que cloneNode perd), vide la valeur de tous les input[type=password], et supprime les <noscript>.
Un aperçu depuis le cache fait tirer turbo:render deux fois. Si l’URL visée est dans le cache, Turbo affiche l’instantané immédiatement pendant que la requête réseau tourne. Pendant cet aperçu, <html> porte l’attribut data-turbo-preview. C’est le moyen de tester dans un contrôleur Stimulus :
connect() {
if (document.documentElement.hasAttribute("data-turbo-preview")) return
this.initialiserLeWidgetCoûteux()
}
Le cache, en chiffres
Le cache Drive est un LRU de dix instantanés, en mémoire, dans l’onglet. Pas de localStorage, pas d’IndexedDB, pas de Cache API. Il meurt à la fermeture de l’onglet, au rechargement, et il est intégralement vidé à chaque soumission de formulaire non sûre. Il ne contient que du HTML : aucune feuille de style, aucune image, aucun script.
Retenez la formulation : le cache de Turbo est une optimisation de latence perçue, ce n’est pas une couche de persistance. Il ne vous donne strictement rien hors ligne.
Trois leviers :
| Ce que vous voulez | Comment |
|---|---|
| Ne jamais mettre cette page en cache | <meta name="turbo-cache-control" content="no-cache"> |
| La mettre en cache mais ne jamais l’afficher en aperçu | <meta name="turbo-cache-control" content="no-preview"> |
| Retirer un élément avant la mise en cache | data-turbo-temporary sur l’élément |
data-turbo-cache="false" a été supprimé en 8.0.21, en janvier 2026, après trois ans de dépréciation. Turbo.clearCache() aussi, remplacé par Turbo.cache.clear(). Beaucoup de billets et de réponses Stack Overflow qui circulent encore les mentionnent.
Turbo.config
Depuis 8.0.6, la configuration passe par un objet unique. Les anciennes fonctions de réglage (setProgressBarDelay, setConfirmMethod, setFormMode) existent toujours mais émettent un avertissement en console.
Turbo.config.drive.progressBarDelay = 500 // ms avant la barre de progression
Turbo.config.drive.enabled = true
Turbo.config.forms.mode = "on" // on | off | optin
Turbo.config.forms.submitter = "disabled" // disabled | aria-disabled
Turbo.config.forms.confirm = async (message) => { /* Promise<boolean> */ }
forms.submitter mérite un mot, parce que c’est un cas où le défaut de Turbo est mauvais pour l’accessibilité. Par défaut, Turbo pose l’attribut disabled sur le bouton pendant la soumission. Un bouton disabled reste exposé dans l’arbre d’accessibilité, mais il perd sa focusabilité et sa place dans l’ordre de tabulation : le focus retombe sur <body>, et une personne qui navigue au clavier ou au lecteur d’écran perd sa position à chaque envoi de formulaire. Avec "aria-disabled", Turbo pose aria-disabled="true" et annule les click sur le bouton : la protection contre le double envoi est identique, mais le contrôle reste focusable.
Turbo.config.forms.submitter = "aria-disabled"
Une ligne, dans application.js. Il n’y a pas de raison de ne pas l’écrire.
Préchargement
Depuis Turbo 8, le préchargement au survol est actif par défaut. Turbo attend 100 ms après le mouseenter, envoie un GET avec l’en-tête X-Sec-Purpose: prefetch, et garde la réponse dans un cache d’une seule entrée pendant 10 secondes.
Cela veut dire que passer la souris au-dessus d’un lien déclenche une requête chez vous. Si vos actions GET ne sont pas idempotentes, ou si votre serveur n’aime pas le trafic gratuit, coupez :
<meta name="turbo-prefetch" content="false"> <%# global %>
<a href="/x" data-turbo-prefetch="false">…</a> <%# ponctuel %>
À ne pas confondre avec data-turbo-preload, qui est un autre mécanisme, un autre cache, et un chargement au DOMContentLoaded plutôt qu’au survol.
Turbo Frames
Le contrat tient dans un identifiant
<turbo-frame> de même id. Tout le reste est jeté.La règle de correspondance, littéralement, est container.querySelector("turbo-frame#" + CSS.escape(id)). Pas de correspondance approchée, pas de sélecteur configurable. C’est pour ça que le patron le plus robuste consiste à laisser le serveur reprendre l’identifiant qu’on lui a envoyé, via la méthode d’aide turbo_frame_request_id de turbo-rails :
<%= turbo_frame_tag turbo_frame_request_id || "invoice_detail" do %>
…
<% end %>
Le repli n’est pas décoratif : sur une visite pleine page, l’en-tête est absent et turbo_frame_tag nil produit id="", ce qui casse silencieusement toute navigation ultérieure vers ce frame.
Il existe une seconde chance, peu utilisée : l’attribut recurse. Si aucun frame de même id n’est trouvé mais qu’un <turbo-frame src recurse~="mon-id"> est présent, Turbo attend son chargement puis va chercher dedans.
Et si rien ne correspond, le déroulé est le suivant :
completeest posé sur le frame, avant l’événement.turbo:frame-missingest émis sur le frame, annulable, avecdetail.response(uneResponsebrute) etdetail.visit(urlOuResponse, options).- Si personne n’annule, le frame affiche
<strong class="turbo-frame-error">Content missing</strong>et Turbo lève uneTurboFrameMissingError.
Le cas le plus fréquent est l’expiration de session : la requête part du frame, le serveur redirige vers /login, et la page de login ne contient évidemment pas votre frame. La bonne réponse n’est pas de gérer l’événement, c’est de marquer la page de login :
<%= turbo_page_requires_reload %>
qui pose <meta name="turbo-visit-control" content="reload">. Turbo saute alors complètement l’extraction du frame et fait une visite pleine page. C’est l’échappatoire prévue, et elle est bien meilleure qu’un écouteur turbo:frame-missing global.
Un piège au passage, et il vaut aussi pour turbo_refreshes_with qu’on verra plus loin : ce helper appelle provide :head. Il n’écrit rien à l’endroit de l’appel, malgré le <%= %>. Sans <%= yield :head %> dans votre layout, la balise meta ne sort jamais et rien ne se passe. La variante turbo_page_requires_reload_tag rend la balise sur place si vous préférez la placer vous-même.
Le layout, et le piège du layout statique
turbo-rails installe ceci dans ActionController::Base :
layout -> { "turbo_rails/frame" if turbo_frame_request? }
etag { :frame if turbo_frame_request? }
Le layout turbo_rails/frame est minimal (juste csrf_meta_tags et yield :head), pas absent, pour que content_for :head et le CSRF continuent de fonctionner.
Le piège : si vous écrivez layout "admin" dans un contrôleur, vous écrasez cette lambda. Les requêtes de frame rendront alors le layout complet. Ça fonctionne quand même, parce que Turbo extrait le frame de toute façon, mais vous payez le layout entier à chaque requête de frame, et tout le JavaScript injecté dans le <head> est réévalué. Il faut convertir la déclaration en méthode :
layout :choisir_layout
private
def choisir_layout
turbo_frame_request? ? "turbo_rails/frame" : "admin"
end
Aucune erreur, aucun avertissement. Juste une facture silencieuse.
Les attributs qui comptent
| Attribut | Effet |
|---|---|
src |
Charge cette URL dans le frame. Passé par url_for, donc un modèle fonctionne. |
loading="lazy" |
Ne charge qu’à l’entrée dans le viewport, via un IntersectionObserver. |
target |
Cible par défaut des liens et formulaires descendants. |
data-turbo-frame |
Sur un lien, un formulaire ou un bouton de soumission. Écrase target. Le bouton gagne sur le formulaire. |
_top |
Sort du frame : Drive traite la navigation comme une visite pleine page. |
_parent |
Vise le frame ancêtre le plus proche. Ajouté en 8.0.21. |
busy |
Posé par Turbo pendant le chargement, avec aria-busy="true". Utile en CSS. |
complete |
Posé après un rendu réussi. Attention : la propriété JS frame.complete ne lit pas cet attribut. |
disabled |
Annule la requête en cours et ignore toute navigation. |
autoscroll |
Fait défiler jusqu’au frame après rendu. data-autoscroll-block vaut end par défaut, pas start. |
refresh="morph" |
Avec src, le frame est rechargé plutôt que morphé pendant un morph de page. |
Deux comportements qui surprennent souvent :
Un frame rend les 4xx et les 5xx exactement comme les 2xx. Le code de statut n’est pas consulté du tout. Deux choses seulement peuvent détourner la réponse : un Content-Type non HTML, auquel cas loadResponse ne fait rien du tout, sans événement ni erreur, et le turbo-visit-control: reload vu plus haut, qui déclenche une visite pleine page avec un avertissement en console.
Un formulaire dans un frame n’a pas besoin de rediriger. La contrainte « les réponses de formulaire doivent rediriger » ne s’applique qu’aux soumissions pleine page. Un 200 OK avec du HTML est parfaitement valide dans un frame.
Turbo Streams
Un <turbo-stream> est une enveloppe. Elle porte une action, une cible, et un <template> :
<turbo-stream action="replace" target="invoice_42">
<template><div id="invoice_42">…</div></template>
</turbo-stream>
Il y a deux mondes derrière ce format, et les confondre est la première source de confusion.
Le premier monde est une réponse HTTP : l’utilisateur fait une action, le serveur répond du text/vnd.turbo-stream.html, l’onglet applique. C’est synchrone, ça vit dans le contexte de la requête, current_user existe.
Le second est un broadcast : un modèle publie sur un canal Action Cable, tous les onglets abonnés reçoivent. C’est asynchrone, ça tourne dans un job, il n’y a ni requête, ni session, ni current_user. On y revient plus bas, parce que c’est là que se cachent les vrais problèmes.
Huit actions, et morph n’en est pas une
C’est l’erreur la plus répandue de tout l’écosystème, y compris dans des billets très lus.
| Action | Ce qu’elle fait | Cible requise |
|---|---|---|
append |
Ajoute à la fin du contenu de la cible | oui |
prepend |
Ajoute au début | oui |
before |
Insère avant la cible | oui |
after |
Insère après la cible | oui |
replace |
Remplace la cible elle-même | oui |
update |
Remplace le contenu de la cible | oui |
remove |
Supprime la cible. Pas de <template> |
oui |
refresh |
Déclenche un rechargement de page. Pas de <template> |
non |
C’est tout. Il n’y a pas d’action morph. Le morphing est un attribut method="morph", et parmi les actions ciblées, seuls replace et update le lisent. Écrire turbo_stream.append("x", method: :morph) produit bien l’attribut dans le HTML, et le gestionnaire JavaScript de append l’ignore purement et simplement. refresh lit aussi method, mais pour une autre raison : y choisir le mode de rendu du rafraîchissement de page, broadcast par broadcast.
Deux comportements peu documentés :
append et prepend dédoublonnent : si un enfant direct de la cible porte le même id qu’un élément entrant de premier niveau, l’ancien est retiré. append se comporte donc comme un upsert, ce qui est très pratique et jamais dit. Depuis 8.0.21, before et after font la même chose sur les frères de la cible.
refresh est déduplicable via un attribut request-id, sujet de la section sur les broadcasts.
target et targets ne sont pas la même chose
turbo_stream.replace "invoice_42" # target => getElementById
turbo_stream.replace_all ".invoice-row" # targets => querySelectorAll
target prend un identifiant DOM nu, pas un sélecteur, et résout au plus un élément. targets prend un sélecteur CSS et applique l’action à toutes les correspondances. Si les deux sont présents, target gagne.
Côté Ruby, le helper vous aide : passer un enregistrement à target: donne dom_id(record), et à targets: donne "#" + dom_id(record). Vous n’avez pas à y penser, sauf le jour où vous construisez le sélecteur à la main.
Le silence quand la cible n’existe pas
C’est le comportement le plus coûteux en temps de débogage de tout Turbo.
Si document.getElementById(target) retourne null, le getter retourne un tableau vide, et chaque action itère sur ce tableau vide. Aucun avertissement, à aucun niveau de log. Le stream arrive, il est visible dans l’onglet Réseau, il est visible dans les logs Rails, et il ne se passe rien.
Les causes classiques : un dom_id mal orthographié ou mis au pluriel, une cible qui vit à l’intérieur d’un <template> ou d’une <iframe> (explicitement non supportés), ou une cible qui est dans un frame loading="lazy" pas encore chargé.
Il existe une seconde variante silencieuse, plus fourbe : sur une requête GET, Turbo n’envoie l’en-tête Accept: text/vnd.turbo-stream.html que si le lien ou le formulaire porte data-turbo-stream. Sans cet attribut, votre respond_to ne verra jamais le format turbo_stream et tombera dans la branche HTML.
Ajouter vos propres actions
Le mécanisme est plus simple qu’il n’y paraît et c’est un bon investissement dès que vous vous surprenez à empiler des streams pour exprimer une seule intention.
Côté Ruby, un hook de chargement :
# config/initializers/turbo.rb
ActiveSupport.on_load :turbo_streams_tag_builder do
def surligner(target) = action :surligner, target
def surligner_tous(targets) = action_all :surligner, targets
end
Côté JavaScript, une entrée dans Turbo.StreamActions, où this est l’élément <turbo-stream> :
Turbo.StreamActions.surligner = function () {
this.targetElements.forEach((el) => {
el.animate([{ backgroundColor: "#FFD83F" }, { backgroundColor: "transparent" }],
{ duration: 1200 })
})
}
Et depuis un modèle, sans passer par le builder :
after_update_commit -> {
broadcast_action_to "plannings", action: :surligner, target: "gantt", html: ""
}
Si l’action n’est pas enregistrée côté JavaScript, l’élément lève unknown action. C’est un des rares endroits où Turbo est bruyant.
Le statut HTTP, et pourquoi 422
Le symptôme numéro un de tout Hotwire tient dans une ligne de code de Turbo :
responseSucceededWithoutRedirect(response) {
return response.statusCode == 200 && !response.redirected
}
Si vous répondez 200 avec du HTML à un POST sans redirection, Turbo affiche console.error("Form responses must redirect to another location") et ne rend rien. La page paraît figée. Le formulaire est parti, la barre de progression est allée au bout, et les messages d’erreur ne sont jamais apparus.
La raison est donnée dans le manuel, et elle est bonne : les navigateurs ont un comportement natif pour le rechargement d’une page issue d’un POST, cette boîte de dialogue « voulez-vous renvoyer le formulaire ? », que Turbo ne peut pas reproduire. Plutôt que de mentir sur l’URL, il refuse.
D’où la convention Rails, que le générateur de scaffold applique déjà :
render :new, status: :unprocessable_entity # 422 : la réponse est rendue, l'URL ne bouge pas
redirect_to @invoice, status: :see_other # 303 : après update et destroy
Le 303 n’est pas une lubie de Rails, c’est une conséquence de la spécification Fetch. Turbo passe redirect: "follow" et laisse le navigateur suivre. Or :
- 303 force toujours le passage en
GET. - 301 et 302 ne forcent le passage en GET que pour POST. Un
DELETEredirigé en 302 est rejoué enDELETEsur l’URL cible. - 307 et 308 préservent toujours la méthode.
Un redirect_to invoices_path après un destroy sans status: :see_other envoie donc un DELETE /invoices à votre serveur. Au mieux une erreur de routage, au pire quelque chose que vous n’aviez pas prévu.
Une nuance qui simplifie la vie : une réponse turbo-stream court-circuite tout ça. Turbo intercepte sur le Content-Type avant même de regarder le statut. render turbo_stream: …, status: :unprocessable_entity fonctionne parfaitement, et le 422 ne sert plus qu’à vos tests et aux clients non-Turbo.
Petit détail amusant : le test porte sur statusCode == 200 exactement. Un 201 Created passe à travers le garde-fou et poursuit vers une visite.
Le morphing
Turbo 8 a introduit les page refreshes avec morphing. L’idée : plutôt que de remplacer le <body>, comparer l’ancien arbre et le nouveau, et ne modifier que ce qui diffère. On y gagne le scroll, le focus, la sélection de texte, l’état des transitions CSS.
On l’active en deux lignes, dans le layout :
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
Et voici le premier piège, responsable d’une bonne part des « le morphing ne marche pas chez moi » : turbo_refreshes_with appelle provide :head. Il n’écrit rien à l’endroit où vous l’appelez. Sans <%= yield :head %> dans votre layout, les balises meta ne sortent jamais, et Turbo continue de faire des remplacements classiques, sans rien signaler. Les valeurs acceptées sont :replace ou :morph pour method:, :reset ou :preserve pour scroll:, tout le reste lève une ArgumentError.
Ce qui déclenche réellement un morph
Un morph n’a lieu que pour un page refresh, et la condition exacte tient en deux clauses : même pathname (la query string et le fragment ne comptent pas) et action === "replace".
En pratique, pour un formulaire, ça revient à « même URL », parce que Turbo ne choisit replace que si l’URL d’arrivée est strictement identique à l’URL de départ. Un POST /invoices/42 qui redirige vers /invoices/42 morphe. Un POST /invoices/42/edit qui redirige vers /invoices/42 ne morphe pas : deux chemins différents, rendu classique.
Mais la clause action compte pour le reste. Un lien data-turbo-action="replace" de /invoices?page=2 vers /invoices?page=3 morphe aussi, puisque le pathname est le même.
Les deux déclencheurs les plus courants : un formulaire qui redirige sur la même URL, et un broadcast <turbo-stream action="refresh">.
Ce que le morphing enlève
Trois corrections à du folklore très répandu :
turbo:load se déclenche bien après un morph. Un refresh est une vraie visite. Ce qui ne rejoue pas, ce sont les <script> inline.
turbo:before-cache ne se déclenche plus. Le refresh passe shouldCacheSnapshot: false. Or turbo:before-cache est le point de démontage global recommandé partout depuis Turbolinks. Activer le morphing désactive donc silencieusement le code de nettoyage de la moitié des applications Rails existantes.
Les scripts ne sont pas « jamais réexécutés », ils sont réexécutés s’ils sont nouveaux. idiomorph apparie l’ancien <script> au nouveau par nom de balise, puis synchronise le contenu textuel par oldNode.nodeValue = newNode.nodeValue. Assigner nodeValue à un script déjà exécuté ne le réexécute jamais. En revanche, un script réellement nouveau, sans partenaire dans l’ancien arbre, est inséré et s’exécute.
idiomorph décide par les identifiants
Un id n’est considéré comme stable que si les trois conditions suivantes sont réunies : il existe dans les deux arbres, le nom de balise est identique, et il n’est dupliqué dans aucun des deux.
Cette troisième condition mérite d’être encadrée. Un seul id dupliqué quelque part dans le document fait sortir cet id de l’ensemble des identifiants persistants, dans les deux copies. L’appariement retombe alors sur la position. Un doublon accidentel dans un partiel dégrade donc le morphing d’éléments qui n’ont rien à voir.
Sans identifiants stables, tout est apparié par position. Ajouter une ligne en tête de liste réécrit le texte de toutes les lignes plutôt que d’insérer un nœud. Conséquences : les transitions CSS repartent partout, et tout état client porté par une ligne (un menu ouvert, une case cochée, une vidéo en lecture) se décale d’un cran.
La règle est courte : donnez un id stable et unique à chaque élément de liste, ne le réutilisez jamais, ne changez jamais la balise associée. dom_id(record) fait exactement ça.
Le garde-manger, et pourquoi ça casse en Safari
Voici le mécanisme qui explique les rapports de bug « ça marche chez moi mais pas chez lui », et qui n’est documenté nulle part.
Quand un nœud à identifiant persistant doit changer de place, idiomorph ne le clone pas. Il le déplace dans un <div> caché inséré après </body>, le garde-manger, puis le remet en position plus tard. Le déplacement utilise parentNode.moveBefore() si le navigateur le fournit, sinon insertBefore.
Ce n’est pas un détail d’implémentation, c’est une bifurcation de comportement :
- Avec
moveBefore(Chromium 133 et plus, Firefox 144 et plus), le nœud reste connecté. Pas dedisconnect/connectStimulus, les<iframe>ne rechargent pas, une<video>continue de jouer. - Avec
insertBefore, le nœud est déconnecté puis reconnecté.disconnect()puisconnect()sont appelés, les<iframe>rechargent, la lecture vidéo repart de zéro, les transitions CSS redémarrent, et lesconnectedCallbackdes éléments personnalisés rejouent.
À la date où j’écris, le camp insertBefore s’est réduit à Safari et WebKit iOS, plus les versions antérieures à Chrome 133 et Firefox 144. Donc oui, un bug de morphing peut être parfaitement reproductible en Safari et introuvable en Chrome. Ce n’est pas votre code.
La valeur des champs n’est pas protégée
idiomorph ne synchronise pas seulement les attributs, il écrit aussi les propriétés DOM vivantes des contrôles de formulaire. Le code est explicite :
if (!newElement.hasAttribute("value")) {
if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
oldElement.value = "" // ce que l'utilisateur avait tapé
oldElement.removeAttribute("value")
}
}
Or un <input type="text"> rendu par le serveur n’a normalement pas d’attribut value. Un refresh qui arrive pendant que quelqu’un tape efface donc ce qu’il tape. Même traitement pour checked, disabled, <option selected> et le contenu des <textarea>.
idiomorph possède une option ignoreActiveValue qui exclut document.activeElement de cette synchronisation. Turbo ne l’active pas. Le contournement est dans les fixtures de test de Turbo, ce qui en fait la réponse la plus officielle qui existe :
addEventListener("focusin", ({ target }) => {
if (target instanceof HTMLInputElement && !target.hasAttribute("data-turbo-permanent")) {
target.toggleAttribute("data-turbo-permanent", true)
target.addEventListener("focusout", () => {
target.toggleAttribute("data-turbo-permanent", false)
}, { once: true })
}
})
Sur le focus, plus précisément : idiomorph restaure le focus, mais uniquement pour un <input> ou un <textarea> qui porte un id. Un <select> focalisé, un contenteditable focalisé ou un champ sans id perdent le focus, et il n’y a pas d’option pour changer ça.
Et l’autofocus n’est pas honoré du tout sous morph : MorphingPageRenderer déclare shouldAutofocus à false.
La boîte à outils de protection
Du plus fin au plus brutal :
// 1. Protéger un attribut précis
document.addEventListener("turbo:before-morph-attribute", (event) => {
const { attributeName } = event.detail // + mutationType: "update" | "remove"
if (attributeName === "open") event.preventDefault()
})
// 2. Protéger un sous-arbre entier
document.addEventListener("turbo:before-morph-element", (event) => {
if (event.target.matches(".widget-tiers")) event.preventDefault()
})
// 3. Réinitialiser après coup
document.addEventListener("turbo:morph-element", ({ target }) => { /* … */ })
<%# 4. Le gel complet %>
<div id="carte" data-turbo-permanent>…</div>
Deux précisions que la documentation ne donne pas.
data-turbo-permanent n’a pas la même sémantique selon le mode de rendu. En rendu classique, le sélecteur est [id][data-turbo-permanent] : l’id est obligatoire, et le nœud vivant est transplanté dans le nouveau corps. Sous morph, seul l’attribut est testé, l’id n’est pas requis, et le nœud est simplement ignoré. L’id redevient nécessaire pour les nœuds ajoutés, où il sert au dédoublonnage.
Sous morph, data-turbo-permanent gèle le sous-arbre complètement. Les mises à jour serveur légitimes à l’intérieur n’arriveront jamais. C’est l’outil de dernier recours, pas le réflexe.
Enfin, turbo:before-morph-element est aussi émis pour les nœuds sur le point d’être supprimés, et dans ce cas detail.newElement vaut undefined. Un écouteur qui écrit event.detail.newElement.matches(…) lèvera une TypeError un jour ou l’autre.
Les broadcasts
C’est là que le modèle mental doit être le plus solide, parce que le code s’écrit en une ligne et que les problèmes arrivent en production.
Quatre macros, et une asymétrie
class Card < ApplicationRecord
broadcasts_refreshes_to :board # 1 seul after_commit, tout part vers board
end
| Macro | Création | Mise à jour | Suppression |
|---|---|---|---|
broadcasts_to :board |
append vers board |
replace vers board |
remove vers board |
broadcasts |
append vers "cards" |
replace vers le flux GID de l’enregistrement |
remove vers le flux GID |
broadcasts_refreshes |
refresh vers "cards" |
refresh vers le flux GID |
refresh vers le flux GID |
broadcasts_refreshes_to :board |
refresh vers board |
refresh vers board |
refresh vers board |
Lisez la deuxième et la troisième ligne deux fois. broadcasts et broadcasts_refreshes envoient les créations vers le flux de la collection, mais les mises à jour et les suppressions vers le flux propre de l’enregistrement. Une page qui ne fait que turbo_stream_from "cards" verra apparaître les nouvelles cartes et ne verra jamais les modifications ni les suppressions.
C’est intentionnel : le patron visé est turbo_stream_from Card sur l’index et turbo_stream_from @card sur la page de détail. Mais ce n’est écrit nulle part de façon lisible. Si vous voulez que les trois événements aillent au même endroit, la macro à utiliser est broadcasts_refreshes_to, qui pose un unique after_commit.
Autre asymétrie utile à connaître : pour les trois premières macros, les suppressions sont synchrones alors que les créations et mises à jour passent par un job. C’est logique (une suppression n’a rien à rendre), mais ça veut dire que la suppression n’est pas anti-rebondie. broadcasts_refreshes_to échappe à ça justement parce qu’elle n’installe qu’un seul after_commit : tout y est asynchrone et anti-rebondi, y compris le destroy.
Le chemin complet d’un refresh
Chaque fetch de Turbo génère un UUID, l’ajoute à un ensemble borné à 20 entrées, et l’envoie en X-Turbo-Request-Id. Côté Rails, un around_action le recopie dans Turbo.current_request_id. Le broadcast le réémet dans l’attribut request-id. À la réception, Session#refresh ignore le refresh si l’identifiant est dans son ensemble local.
La raison est bonne : l’onglet qui a écrit a déjà affiché le résultat de sa requête. Rejouer le refresh coûterait un aller-retour inutile, la position de scroll et le focus.
Quatre manières de perdre cette protection, toutes vérifiables dans le code :
broadcast_refresh_to, la variante synchrone, ne transmet aucunrequest_id. Seule la variante_later_le fait. C’est aussi la variante qu’installe la macro pour ledestroy.- Depuis un job de fond,
Turbo.current_request_idest nul : c’est unthread_mattr_accessorposé par unaround_action. Tous les onglets rafraîchissent, y compris celui à l’origine. C’est en général ce que vous voulez. - L’ensemble est borné à 20. Plus de 20 requêtes Turbo entre l’envoi et l’arrivée du broadcast et l’identifiant est évincé. Avec le préchargement au survol actif par défaut, ce n’est plus si théorique.
turbo_stream.refreshrendu en réponse directe à une requête porte par défaut l’identifiant de cette requête, donc l’onglet demandeur l’ignore. Il faut écrireturbo_stream.refresh(request_id: nil).
L’anti-rebond, et ce qu’il coûte
broadcast_refresh_later_to passe par un Turbo::ThreadDebouncer mémorisé dans Thread.current, clé (nom de flux, request_id), qui programme un Concurrent::ScheduledTask 0,5 seconde dans le futur. Chaque nouvel appel annule le précédent. Mille enregistrements modifiés dans une requête donnent donc un broadcast.
Côté client, Session#refresh est en plus anti-rebondi à 150 ms.
Deux conséquences moins agréables :
Dans un processus court, le broadcast n’est jamais envoyé. Un rails runner, une tâche rake, un conteneur qui sort après son travail : le processus se termine avant que la tâche programmée ne se déclenche. Aucune erreur. Le contournement documenté est un sleep Turbo::Debouncer::DEFAULT_DELAY + 0.1.
Dans les tests, il n’y a pas d’anti-rebond du tout. turbo-rails installe un Turbo::ImmediateDebouncer en environnement de test. Vos assertions comptent N broadcasts, la production en verra 1.
current_user n’existe pas dans un broadcast
Tout broadcast asynchrone rend son partiel via :
ApplicationController.render(formats: [format], **rendering)
C’est-à-dire un ActionController::Renderer avec un environnement Rack synthétique. Il n’y a ni session, ni cookies, ni clé Warden. Conséquences mécaniques :
current_uservautnil. Devise retournenilplutôt que de lever : un partiel qui masque un bouton d’administration derrièreif current_user.admin?rend donc la branche non-admin pour tout le monde, silencieusement, sans rien en console ni dans les logs.- Les attributs
Current.*sont vides, pour la même raison. ApplicationControllerest codé en dur. Pas deprepend_view_path, pas d’ivars posées par unbefore_action, pas de helpers d’un autre contrôleur.- Les helpers
_urllèventMissing host to link to!sous Rails 7.1 et suivants, parce que les valeurs par défaut du renderer n’incluent plus dehttp_host. Les helpers_pathfonctionnent.
Le patron qui passe à l’échelle est le seul qui accepte que le HTML diffusé soit le même pour tous les destinataires : diffuser un partiel neutre, et faire charger les parties personnalisées par un frame, que chaque navigateur ira chercher avec ses propres cookies.
<%# le partiel diffusé, identique pour tous %>
<div id="<%= dom_id(card) %>">
<%= card.title %>
<%= turbo_frame_tag "#{dom_id(card)}_actions", src: card_actions_path(card), loading: :lazy %>
</div>
Les alternatives : diffuser un flux par utilisateur, ce qui est correct mais en O(utilisateurs), ou passer explicitement tout ce dont vous avez besoin par locals: et traiter « ce partiel est diffusable » comme une propriété stricte du partiel.
C’est aussi, incidemment, le meilleur argument en faveur de broadcasts_refreshes : un refresh ne rend rien côté serveur. Chaque navigateur refait sa propre requête, avec sa propre session. Le problème disparaît par construction.
Un flux signé n’est pas un flux autorisé
<%= turbo_stream_from "quotes" %>
Cette ligne produit un signed-stream-name identique pour tous les utilisateurs. La signature empêche la falsification, pas la lecture : le nom est un MessageVerifier, donc du base64 signé, pas du chiffré. Deux base64 -d séparent le source de votre page de la chaîne gid://app/Account/5, et personne n’a besoin de votre clé pour les faire.
Et Turbo::StreamsChannel#subscribed accepte l’abonnement sans aucune vérification supplémentaire. Il vérifie que la signature est valide, point. Quiconque possède un nom signé valide peut s’abonner, y compris un ancien salarié dont vous avez révoqué l’accès, indéfiniment : ces noms n’expirent pas.
Il faut donc porter le périmètre dans le nom du flux :
broadcasts_refreshes_to ->(quote) { [quote.company, :quotes] }
<%= turbo_stream_from current_company, :quotes %>
Et si vous avez besoin d’une vraie autorisation, par exemple parce que l’appartenance peut être révoquée, il faut écrire votre propre canal et faire le contrôle avant stream_from.
Deux pièges de plomberie
En développement, l’adaptateur Action Cable par défaut est async, mono-processus. Un broadcast déclenché depuis bin/rails console n’atteindra jamais un navigateur connecté à un serveur lancé séparément. Passez sur redis dans config/cable.yml, ou utilisez <%= console %> pour déclencher dans le même processus.
Les jobs de turbo-rails héritent de ActiveJob::Base, pas de votre ApplicationJob. Votre politique de réessai, votre file et vos callbacks ne s’appliquent pas. Les trois jobs font discard_on ActiveJob::DeserializationError : un enregistrement supprimé entre la mise en file et l’exécution fait disparaître le broadcast, sans réessai et sans erreur.
Stimulus et les bibliothèques tierces
Le cycle de vie, précisément
Stimulus ne sait rien de Turbo. Il réagit à un MutationObserver, dans la micro-tâche qui suit chaque modification.
Sur une visite classique, l’ordre est : turbo:before-cache, puis l’échange de <body>, donc disconnect() des anciens contrôleurs et connect() des nouveaux, puis turbo:render et turbo:load. Notez que disconnect() arrive après turbo:before-cache : l’instantané est pris avant. C’est pour ça que turbo:before-cache était historiquement le bon endroit pour défaire ce qu’une bibliothèque avait injecté.
Sous morph, il n’y a ni turbo:before-cache, ni disconnect/connect pour les éléments modifiés en place. Les callbacks ne se déclenchent que si le nœud est réellement ajouté ou supprimé, s’il est reparenté via le garde-manger sur le chemin insertBefore, ou si la valeur de data-controller change.
Le patron de reconnexion recommandé vient, là encore, des fixtures de test de Turbo :
addEventListener("turbo:morph-element", ({ target }) => {
for (const { element, context } of application.controllers) {
if (element === target) {
context.disconnect()
context.connect()
}
}
})
C’est cher sur une grande page, puisque ça reconnecte tous les contrôleurs de tous les éléments morphés, mais c’est la réponse la plus officielle qui existe.
Ce qui casse, et pourquoi
La règle générale : toute bibliothèque qui injecte du DOM que le serveur ne rend pas, ou qui écrit du class ou du style à l’exécution, est incompatible avec le morphing sans protection explicite.
| Bibliothèque | Ce qui casse | La cause | La parade |
|---|---|---|---|
| Chart.js, Chartkick | Le graphique disparaît, un « Loading… » reste | Chartkick écoute turbo:before-render et détruit tous les graphiques, y compris quand renderMethod vaut morph. Le <script> qui le recréerait ne se réexécute pas |
Chartkick.config.autoDestroy = false, puis redessiner sur turbo:morph |
| Alpine.js | Les éléments redeviennent invisibles, les :class sautent |
Alpine retire x-cloak à l’init et écrit class et style.display à l’exécution. Le HTML serveur ne les connaît pas, le morph les rétablit |
Annuler turbo:before-morph-attribute pour x-cloak, class et style sur les sous-arbres [x-data] |
| Tom Select, Select2, Choices | Le widget disparaît, la sélection revient en arrière | Le <div> injecté n’est pas dans le HTML serveur, donc supprimé. Le <select> d’origine survit, donc pas de reconnexion |
data-turbo-permanent avec un id, ou détruire et réinitialiser sur turbo:morph-element |
| Leaflet, Mapbox GL | Carte morte, tuiles grises, ou Map container is already initialized |
Les panneaux injectés sont supprimés, le conteneur survit | map.remove() dans disconnect(), invalidateSize() après reconnexion |
<dialog> ouvert en showModal() |
La page devient inutilisable | Le morph réécrit le contenu mais ne réinitialise pas le top layer du navigateur | Ticket ouvert et non résolu. Fermer le dialogue sur turbo:before-render, ou l’exclure du morph |
<details> |
Les panneaux s’ouvrent ou se ferment tout seuls chez tous les spectateurs | open est synchronisé comme un attribut ordinaire |
Annuler turbo:before-morph-attribute pour attributeName === "open" |
<turbo-cable-stream-source> |
Des broadcasts sont perdus | S’il est reparenté sur le chemin insertBefore, il se désabonne puis se réabonne |
Lui donner un id stable et le sortir des zones réordonnées |
Trix et Action Text sont corrigés depuis mars 2025, contrairement à ce qu’affirment encore beaucoup de billets. La technique employée mérite d’être connue, parce que c’est le meilleur patron général pour un élément personnalisé sous morph : <trix-editor> pose un attribut connected à l’initialisation et le déclare dans observedAttributes. Le HTML serveur ne le contient pas, donc le morph le retire, attributeChangedCallback se déclenche, et l’élément se réinitialise tout seul.
C’est un élément personnalisé auto-réparant. Si vous écrivez le vôtre, faites ça.
Ce qui fuit si vous ne nettoyez pas
Turbo transforme votre application en processus long. Tout ce que vous créez dans connect() doit être démonté dans disconnect(), sans exception : les setInterval, les addEventListener sur window ou document, les IntersectionObserver et ResizeObserver (qui maintiennent en vie le nœud observé et bloquent la collecte de tout le sous-arbre détaché), les abonnements Action Cable, les instances Chart.js.
Le cas des contextes WebGL vaut d’être cité : les navigateurs les plafonnent autour de seize. Une carte Mapbox non détruite, seize navigations, et tous vos canvas deviennent blancs.
Injecter un stream sans réponse HTTP
Turbo.renderStreamMessage() accepte une chaîne HTML contenant des <turbo-stream> et les applique comme s’ils étaient arrivés par le réseau. Les <script> à l’intérieur des <template> sont activés, les éléments permanents préservés, le focus restauré par id.
C’est ce qu’il faut pour : un transport autre qu’Action Cable (WebSocket brut, SSE, postMessage depuis un service worker), une mise à jour optimiste synthétisée côté client avant confirmation du serveur, ou un shell natif qui pousse du HTML dans la webview.
Turbo.renderStreamMessage(
`<turbo-stream action="append" target="messages"><template>…</template></turbo-stream>`
)
Réseau et hors-ligne
Quand la requête échoue, il ne se passe rien
Turbo émet turbo:fetch-request-error, qui remonte et traverse les shadow roots, avec detail.request et detail.error. Un écouteur posé sur document l’attrape de façon fiable.
Ce qui arrive ensuite dépend du contexte, et la différence est brutale.
Pour un frame ou une soumission de formulaire, l’utilisateur ne voit rien. Pas de bandeau, pas de toast. console.error, et le frame reste sur son ancien contenu.
Pour une visite Drive, Turbo recharge complètement la page. L’échec réseau est enregistré comme SystemStatusCode.networkFailure, l’adaptateur émet turbo:reload avec reason: "request_failed" et fait window.location.href = …. Hors ligne, cela veut dire : la page d’erreur réseau du navigateur, et votre application détruite. C’est le pire moment pour perdre l’état client.
Ce détail change la nature de l’écouteur ci-dessous. Le preventDefault() n’est pas cosmétique : il fait retourner false à la garde interne de Turbo, ce qui court-circuite la gestion d’erreur et donc le rechargement.
document.addEventListener("turbo:fetch-request-error", (event) => {
event.preventDefault() // empêche aussi le rechargement complet sur une visite Drive
afficherBandeauHorsLigne(event.detail.error)
})
window.addEventListener("offline", () => afficherBandeauHorsLigne())
window.addEventListener("online", () => masquerBandeauHorsLigne())
Attention à un piège d’ordre : turbo:before-fetch-response ne se déclenche que s’il y a une réponse. Une détection de perte réseau construite dessus ne se déclenchera jamais quand le réseau est réellement coupé.
Turbo et les service workers
Les navigations Turbo sont des appels window.fetch(). Turbo ne passe pas d’option mode, donc côté service worker la requête a mode === "cors" et, ce qui surprend davantage, destination === "", la valeur par défaut de tout ce qui sort de fetch().
Un service worker qui route sur request.mode === 'navigate' rate donc toutes les navigations Drive. Et le correctif qu’on lit partout, ajouter || request.destination === 'document', ne rattrape rien du tout : ce destination n’existe que pour les navigations initiées par le navigateur lui-même. Il faut discriminer sur ce que Turbo, lui, envoie vraiment :
const estNavigationDocument = ({ request }) =>
request.method === 'GET' && (
request.mode === 'navigate' || // navigation navigateur
(request.destination === '' && // fetch() de Turbo
(request.headers.get('Accept') || '').includes('text/html'))
)
registerRoute(estNavigationDocument, new NetworkFirst())
registerRoute(
({ request }) => ['style', 'script', 'image', 'font'].includes(request.destination),
new CacheFirst()
)
La seconde route, elle, fonctionne telle quelle : ces requêtes sont bien initiées par le navigateur et portent un destination renseigné.
Deux façons de casser Turbo depuis un service worker :
Servir une réponse au mauvais Content-Type. Turbo enregistre contentTypeMismatch et abandonne la visite en silence.
Servir un HTML dont les empreintes d’assets ne correspondent plus. Turbo compare les éléments data-turbo-track="reload" entre instantanés. En cas de divergence, il déclenche un rechargement complet du navigateur, auquel le service worker répond de nouveau depuis le cache. Vous avez une boucle de rechargement.
Trois contraintes propres à Rails 8 :
- Placez le service worker à une URL racine stable. Un asset digéré par Propshaft ne peut pas contrôler une portée au-delà de son répertoire, et son URL change dès que son contenu change. Deux options :
public/service-worker.js, ou la route que Rails 8 génère, qui est commentée par défaut dansconfig/routes.rbet qu’il faut donc décommenter :get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker. Notez le contrôleurrails/pwaet le chemin sans.js. - Les importmaps ne s’appliquent pas dans un service worker. Il n’y a pas de
<script type="importmap">là-bas.importScripts()ou un worker classique sans imports. - Les URL d’assets digérés changent avec leur contenu. Une liste de préchargement écrite à la main périme au premier déploiement qui touche un fichier. Préférez la mise en cache à l’exécution, par
request.destination.
Un patch officiel est en cours (turbo#1427, une API Turbo.offline livrée dans un bundle séparé) mais il est ouvert, pas fusionné. N’écrivez pas d’architecture qui en dépende.
Action Cable ne rejoue rien
C’est, à mon avis, le risque de production le moins documenté de tout Hotwire.
Action Cable est du publish/subscribe sans persistance, sans accusé de réception, sans numéro de séquence et sans historique. Un message publié pendant qu’un consommateur est déconnecté est délivré à ceux qui sont abonnés à cet instant, puis jeté. Il n’y a rien dans le protocole capable de le rejouer.
Action Cable se reconnecte tout seul, et l’onglet reçoit de nouveau les messages suivants. Tout ce qui est passé pendant la coupure est définitivement perdu. La page est alors silencieusement, indéfiniment périmée. Pas d’erreur, pas d’indication, rien.
Wifi qui bascule, ordinateur qui se réveille, tunnel, déploiement : ce n’est pas un cas limite, c’est le quotidien d’un utilisateur mobile.
Toute la surface d’observation tient dans un attribut : <turbo-cable-stream-source> pose et retire connected. Aucun événement n’est émis. D’où la parade, qui est du code applicatif :
// contrôleur Stimulus posé sur <turbo-cable-stream-source>
connect() {
this.observer = new MutationObserver(() => {
const connecte = this.element.hasAttribute("connected")
if (connecte && this.etaitDeconnecte) {
Turbo.visit(location.href, { action: "replace" })
}
this.etaitDeconnecte = !connecte
})
this.observer.observe(this.element, { attributeFilter: ["connected"] })
}
disconnect() { this.observer.disconnect() }
Traitez aussi visibilitychange (le réveil de la machine) et window.online.
Avec turbo_refreshes_with method: :morph, scroll: :preserve, ce rattrapage coûte un aller-retour et préserve le scroll et le focus. C’est le meilleur argument pratique en faveur du couple morphing plus broadcasts : le rattrapage devient assez bon marché pour qu’on l’écrive.
Dernière chose à savoir dans le même registre : Session#refresh abandonne aussi un refresh qui arrive pendant qu’une navigation est déjà en cours (!this.navigator.currentVisit), sans réessai. C’est un second chemin, plus étroit, vers la même péremption silencieuse.
L’arbre de décision
Le test qui tranche pour les frames est celui de l’URL : s’il n’existe pas d’URL qui rende ce fragment seul, ce n’est pas un frame que vous voulez. Un frame est un mini-navigateur, avec une adresse, un état de chargement et un historique. Sans adresse propre, il ne vous apporte rien et vous coûte le contrat d’identifiant.
Le test qui tranche pour les streams est celui de l’origine : si personne n’a rien demandé, c’est un broadcast. Si quelqu’un vient de cliquer et que plusieurs zones doivent changer, c’est une réponse .turbo_stream. Ce sont deux usages très différents qui partagent un format.
Index par symptôme
| Symptôme | Cause la plus probable |
|---|---|
| Le formulaire part, rien ne bouge, console : « Form responses must redirect » | 200 avec du HTML sur un POST. Répondre 422 sur échec, 303 sur succès |
| Le formulaire part, rien ne bouge, aucune erreur | Un ancêtre data-turbo="false", ou le formulaire est dans un frame que vous n’aviez pas vu |
| Le frame se vide, « Content missing » | La réponse n’a pas de <turbo-frame> de même id. Souvent une redirection vers /login. Marquer la page cible avec turbo_page_requires_reload |
| Le stream arrive, visible dans le réseau, aucun effet | La cible n’existe pas dans le DOM. C’est un no-op totalement silencieux |
| Le stream n’arrive pas sur un lien GET | Ajouter data-turbo-stream : sinon l’en-tête Accept n’est pas envoyé |
first child element must be a <template> element |
Un <turbo-stream> construit à la main sans son <template> |
Le Content-Type est mauvais et il ne se passe rien du tout |
Turbo teste startsWith("text/vnd.turbo-stream.html"). Sinon il n’intercepte pas, et la réponse repart dans le traitement normal, sans erreur |
| Le JS s’arrête de fonctionner après une navigation | Initialisation sur DOMContentLoaded, qui ne se déclenche qu’au premier chargement. Passer à turbo:load ou à Stimulus |
| Le JS s’arrête seulement depuis qu’on a activé le morphing | Les <script> inline ne se réexécutent pas, et turbo:before-cache ne se déclenche plus |
| Le morphing « ne marche pas », la page se remplace | <%= yield :head %> manquant dans le layout : turbo_refreshes_with n’écrit rien sans lui |
| Le morphing ne se déclenche pas après un formulaire | La requête doit partir et arriver sur la même URL |
| Rechargement complet du navigateur à chaque navigation | Divergence de signature data-turbo-track="reload". Normal après un déploiement. En boucle, chercher un asset injecté dynamiquement ou un service worker |
| Les listes s’animent n’importe comment sous morph | Pas d’id stable sur les éléments, ou un id dupliqué ailleurs dans le document, ou la balise a changé |
| Ça marche en Chrome, ça casse en Safari, sous morph | moveBefore contre insertBefore dans le garde-manger d’idiomorph. Ce n’est pas votre code |
| Le texte que l’utilisateur tape est effacé | syncInputValue écrit les propriétés vivantes, et Turbo n’active pas ignoreActiveValue |
| Le bouton retour affiche un contenu périmé | Comportement voulu : l’instantané en cache est rendu d’abord. turbo-cache-control: no-cache si le contenu est sensible |
| Le bouton retour affiche un widget en double | L’instantané a gardé le DOM injecté par le widget. Le détruire dans disconnect(), pas dans turbo:before-cache si vous morphez |
| Les messages flash n’apparaissent pas sous morph | Un broadcast refresh ne porte aucun flash. Et si le flash a le même id et le même texte, le morph ne modifie rien du tout |
| Un broadcast met à jour la page du mauvais utilisateur | Nom de flux non cloisonné. Signer n’est pas autoriser |
| Un broadcast arrive deux fois chez l’auteur | Variante synchrone sans request_id, ou broadcast depuis un job |
| Les mises à jour temps réel s’arrêtent, sans erreur | Le WebSocket est tombé. Action Cable ne rejoue rien. Rattraper sur l’attribut connected |
| Rien ne se passe quand le réseau est coupé, dans un frame ou un formulaire | turbo:fetch-request-error est émis mais personne ne l’écoute |
| La page d’erreur du navigateur apparaît quand le réseau est coupé, sur un clic de lien | Une visite Drive en échec réseau déclenche un rechargement complet. preventDefault() sur turbo:fetch-request-error l’empêche |
Les modifications ne sont jamais diffusées depuis un rails runner |
Le processus se termine avant la tâche anti-rebond programmée à 0,5 s |
Ce qui a changé récemment
Si vous relisez de la documentation ou des billets antérieurs à 2026, méfiez-vous de ces points, tous modifiés dans la version 8.0.21 de janvier 2026 :
data-turbo-cache="false"a été supprimé. C’estdata-turbo-temporary.Turbo.clearCache()a été supprimé. C’estTurbo.cache.clear().data-turbo-frame="_parent"a été ajouté.- Les attributs
methodetscrollsur l’action de streamrefreshont été ajoutés : ils écrasent les balises meta, broadcast par broadcast. beforeetafterdédoublonnent désormais les frères, commeappendetprependle faisaient déjà pour les enfants.
Et quelques éléments présents dans le code mais absents de la référence officielle, qui valent d’être connus : l’événement turbo:before-frame-morph, les options Turbo.config.forms.mode, Turbo.config.forms.submitter, Turbo.config.drive.enabled et Turbo.config.drive.unvisitableExtensions, et le fait que turbo:frame-render soit déclaré annulable alors que l’annuler ne produit aucun effet (la valeur de retour est jetée).
Enfin, les versions de référence de cet article sont figées à Turbo 8.0.23 et turbo-rails 2.0.23. Presque tout ce qui précède a été vérifié en lisant le code de ces deux versions plutôt que la documentation, précisément parce que c’est là que sont les écarts.