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 question qui compte au moment d’écrire : lequel, ici, maintenant.
Pour choisir, je regarde deux choses : la portion du document qui change et le moment où sa cible est décidée. Drive vise implicitement la page entière. Un frame nomme son fragment avant l’envoi de la requête. Un stream laisse le serveur désigner ses cibles dans la réponse. Cette lecture donne trois périmètres de mutation : la page entière, un fragment nommé, ou un ensemble d’éléments désignés dans une réponse.
remove, refresh, les actions personnalisées et les broadcasts ne sont pas littéralement des remplacements, mais la grille reste utile. Le morphing change la manière d’appliquer la mutation, pas son périmètre.
J’aurais aimé disposer de cette grille il y a trois ans. Le modèle et le tableau suffisent pour choisir ; la checklist et l’index servent au diagnostic. Le reste documente Turbo 8.0.23.
Versions et niveau de garantie
Cet article cible Turbo 8.0.23 et turbo-rails 2.0.23, publiés le 29 janvier 2026, avec Rails 8. Les détails ci-dessous viennent surtout du code et des tests de ces versions épinglées. Les encadrés qui s’appuient sur une garantie particulière utilisent les étiquettes suivantes :
Les liens vers le code de Turbo et turbo-rails pointent sur les tags v8.0.23 et v2.0.23, donc sur des lignes figées : elles diront toujours ce que je leur fais dire, même quand la branche main aura bougé.
Le modèle en une page
Avec Drive, la cible est implicite : c’est le <body>. Elle n’est jamais négociée, il n’y a donc aucun contrat d’identifiant à respecter. 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 à l’origine de la requête envoie son identifiant dans le header Turbo-Frame. Sur le chemin d’extraction normal, Turbo n’accepte que l’élément portant ce même identifiant, directement ou au bout d’un frame recurse. Le serveur ne peut pas rediriger le contenu vers un autre frame. Il peut seulement court-circuiter l’extraction avec turbo-visit-control: reload, qui transforme la réponse en visite pleine page.
Avec Streams, la cible est écrite dans la réponse, donc décidée au dernier moment, par le serveur. Il 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.
À retenir
Deux des trois primitives passent un contrat avec le DOM. Pour un frame, le chemin d’extraction normal doit trouver l’identifiant attendu, directement ou via
recurse; sinon Turbo émetturbo:frame-missingpuis, par défaut, affiche « Content missing » et lève une erreur. Un listener peut annuler l’événement pour prendre le relais.turbo-visit-control: reloadcontourne entièrement ce chemin avec une visite pleine page. Pour un stream, le contrat est la cible doit exister dans le document au moment où l’ordre arrive ; une cible absente est, elle, un no-op silencieux.
Quelle primitive choisir ?
Le tableau ci-dessous est la version courte. Il ne classe pas les outils du plus simple au plus avancé. Partez de l’origine de l’état, puis choisissez la première ligne qui décrit réellement le besoin.
| Votre besoin | La primitive | Pourquoi ce choix |
|---|---|---|
| L’état est purement client et le serveur n’a rien à en savoir | Stimulus, sans Turbo | Un aller-retour réseau pour ouvrir un menu est un aller-retour de trop |
| L’URL et la page entière représentent le nouvel état | Drive, c’est-à-dire rien à écrire | C’est déjà actif et fournit l’historique ainsi que le bouton retour |
Une seule zone change, et une URL répond avec un <turbo-frame> de même identifiant |
Frame | Turbo gère nativement le lazy loading (loading="lazy"), le loading state et la navigation interne. data-turbo-action peut promouvoir cette navigation dans l’historique |
| Plusieurs zones non contiguës changent en réponse à une action de l’utilisateur | Réponse .turbo_stream |
Un frame ne peut viser qu’un fragment. Découper la page en cinq frames pour simuler un stream multiplie les requêtes |
| Le changement doit atteindre un navigateur qui n’a pas lancé la requête courante | Broadcast | Le destinataire n’a pas de réponse HTTP à appliquer. Lorsque Turbo rend lui-même le HTML du broadcast, son renderer synthétique n’a ni session ni current_user exploitable |
Reste le cas où les deux semblent possibles.
Le test de l’URL, pour les frames. S’il n’existe pas d’URL dont la réponse contient ce fragment sous un <turbo-frame> de même identifiant, ce n’est probablement pas un frame que vous voulez. Un frame est un contexte de navigation : les liens et formulaires qu’il capture ont une URL et un loading state ; l’historique reste optionnel via data-turbo-action.
Le test du destinataire, pour les streams. Si seul l’onglet qui vient d’agir doit changer, c’est une réponse .turbo_stream. Si d’autres onglets doivent recevoir la mutation sans avoir lancé cette requête, c’est un broadcast. Ce sont deux usages très différents qui partagent un format, et les confondre est une source classique de HTML diffusé au mauvais utilisateur.
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 principaux sans touche de modification sur les <a href> navigables, ainsi que les form submissions navigables, tant que leur destination est visitable. Une URL visitable reste sous le <meta name="turbo-root"> de la page (/ par défaut) et son extension ne figure pas dans Turbo.config.drive.unvisitableExtensions (une cinquantaine d’extensions, dont .pdf, .zip, .csv, .jpg). Les URL cross-origin sont donc exclues avec le turbo-root par défaut. Cette liste d’extensions est configurable et n’est documentée nulle part sur le site officiel.
Turbo laisse aussi le browser gérer les liens download, tous les liens dont target diffère de _self, ainsi que les formulaires en method="dialog".
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 le réactive.
Le cycle de vie
Il n’existe pas de séquence universelle. Le tableau suit un link en advance qui ouvre une visite Drive avec un fetch. Il suppose que le document de départ est cacheable et qu’aucun snapshot de preview n’existe. Une form submission pleine page effectue d’abord sa propre requête, tandis qu’une restauration depuis le cache saute les événements de fetch. Au chargement initial, turbo:load est émis sans turbo:render. Les render hooks peuvent aussi s’exécuter deux fois lors d’une preview. La dernière colonne reste la plus utile, parce que plusieurs noms sont trompeurs.
| Événement | Émis sur | Annulable | Ce qu’il permet vraiment |
|---|---|---|---|
turbo:click |
le <a> cliqué |
✓ | Annuler pour laisser le navigateur faire une navigation classique |
turbo:before-visit |
<html> |
✓ | Le dernier endroit où l’on peut refuser la visite |
turbo:before-fetch-request |
<html> sur une visite, le frame ou le formulaire concerné, le <a> sur un prefetch ou un preload |
✓ | preventDefault() ne bloque pas la requête, il la met en pause jusqu’à l’appel de detail.resume() |
turbo:visit |
<html> |
✕ | Informatif. detail.action vaut advance, replace ou restore |
turbo:before-fetch-response |
<html> sur une visite, le frame ou le formulaire concerné, le <a> sur un prefetch ou un preload |
✓ | Annuler empêche le delegate Visit/Frame/FormSubmission de traiter la réponse. Cela ne bloque pas à lui seul un turbo-stream : StreamObserver ignore defaultPrevented et peut l’appliquer depuis ce même événement |
turbo:before-cache |
<html> |
✕ | Nettoyer le DOM avant le cache. Le clone est différé au prochain event loop tick |
turbo:before-render |
<html> |
✓ | Même sémantique de pause. detail.newBody est modifiable avant rendu, detail.renderMethod vaut replace ou morph |
turbo:render |
<html> |
✕ | Le nouveau corps est en place |
turbo:load |
<html> |
✕ | Fin de la visite. Au premier chargement, Turbo l’émet quand readystatechange passe à interactive ou complete ; un bundle async chargé après DOMContentLoaded peut démarrer trop tard et le manquer |
Première surprise, turbo:before-fetch-request précède turbo:visit. Visit#start() appelle this.adapter.visitStarted(this), qui prépare la requête, avant this.delegate.visitStarted(this), qui émet turbo:visit (visit.js#L114-L118). Le fetch() effectif attend encore la fin de l’interception, mais un listener installé depuis turbo:visit a déjà manqué turbo:before-fetch-request. Instrumentez cet événement directement si vous devez observer ou modifier toutes les requêtes.
L’autre surprise tient à un défaut de dispatch() : sans target explicite, il émet sur document.documentElement, pas sur document (util.js#L29-L44). Avec l’event bubbling, écouter sur document fonctionne, mais event.target vaut <html>, ce qui compte si vous filtrez dessus. C’est le cas des deux événements de fetch d’une visite Drive. Un fetch de frame cible le frame, celui d’une form submission cible le <form>, et ceux du prefetch ou du preload ciblent le <a>.
Et les événements propres aux frames et aux formulaires, qui s’intercalent dans la même séquence :
| Événement | Émis sur | Annulable | Ce qu’il permet vraiment |
|---|---|---|---|
turbo:before-frame-render |
le frame | ✓ | Pause, et detail.render est remplaçable : c’est le point d’entrée officiel pour brancher un autre moteur de rendu |
turbo:frame-render |
le frame | ✕ | Déclaré cancelable: true, mais la valeur de retour du dispatch est jetée : l’annuler ne produit rien |
turbo:frame-load |
le frame | ✕ | Le frame a fini |
turbo:submit-start |
le <form> |
✕ | detail.formSubmission |
turbo:submit-end |
le <form> |
✕ | Toujours detail.formSubmission. Une réponse traitée par FormSubmission ajoute success et fetchResponse ; une network error ajoute success: false et error. Ces clés restent absentes après un abort, ainsi qu’après le guard Form responses must redirect d’une réponse unsafe 200 non redirigée. |
Sous le capot vérifié
turbo:before-fetch-requestfait une chose de plus que ce que son nom laisse croire. Sa mise en pause est documentée dans le handbook, mais pas le fait que Turbo relise l’URL après coup :this.url = event.detail.url(fetch_request.js#L183-L199), et c’est bien cethis.urlqui part aufetchjuste après. Réécrireevent.detail.urldepuis un listener change donc réellement l’URL appelée, ce qui est le moyen le plus court d’ajouter un préfixe de tenant à toutes les requêtes Turbo d’un coup.Encore faut-il le faire de façon synchrone dans le listener : l’affectation a lieu avant le
awaitde la pause, donc modifierdetail.urlaprès unpreventDefault()et avantresume()n’a aucun effet. Et affectez un objetURL, pas une chaîne : Turbo litthis.url.href, une chaîne donneundefined. Ce n’est pas dans le handbook, mais c’est couvert par la suite de tests du dépôt (form_submission_tests.js#L191-L204), ce qui en fait une garantie plus solide qu’il n’y paraît.
Le reste des bugs de cycle de vie vient presque toujours du snapshot ou de la preview.
Le snapshot vient de la page qu’on quitte, pas de celle qu’on charge. turbo:before-cache se déclenche sur le document courant, puis PageView#cacheSnapshot() attend le prochain event loop tick avant le cloneNode(true). Le nettoyage synchrone dans ce hook reste le chemin déterministe. En revanche, la visite n’attend pas la fin de cacheSnapshot() : le remplacement du <body> et un disconnect() Stimulus peuvent donc modifier l’ancien DOM avant le clone. Un disconnect() n’arrive pas nécessairement trop tard pour nettoyer le cache.
Le clone perd les listeners mais garde le DOM injecté qui existe encore à cet instant. C’est ainsi qu’une bibliothèque peut se retrouver initialisée deux fois au retour arrière ; on y revient plus loin.
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>.
Une preview depuis le cache peut déclencher turbo:render deux fois, mais pas sur le retour arrière. Sur une visite advance ou replace, Turbo utilise un snapshot en cache seulement s’il est previewable et, en présence d’une ancre, s’il contient cette ancre. Il le rend pendant que le fetch continue ; si la réponse fraîche est elle-même rendue, un second turbo:render suit. Pendant le premier render, <html> porte l’attribut data-turbo-preview.
La nuance compte, et je l’ai eue fausse pendant longtemps. Une visite de restauration avec un snapshot utilisable n’émet aucune requête et rend une seule fois. Sans snapshot utilisable, elle repart sur le network. advance et replace décrivent des actions d’historique, pas seulement des clics de lien ou des form submissions : Turbo.visit() et un page refresh peuvent aussi les produire.
Sous le capot interne
Tout tient dans une méthode de six lignes :
shouldIssueRequest()renvoie!this.hasCachedSnapshot()quandthis.action == "restore", etthis.willRendersinon (visit.js#L377-L383). OrloadCachedSnapshotcalcule sa preview parconst isPreview = this.shouldIssueRequest()(visit.js#L245-L265). Pas de requête, donc pas de preview, donc un seul rendu.
En pratique, la guard clause à écrire dans un contrôleur Stimulus reste la même :
connect() {
if (document.documentElement.hasAttribute("data-turbo-preview")) return
this.initExpensiveWidget()
}
Le cache, en chiffres
Le cache Drive est un LRU de dix snapshots, en mémoire, dans l’onglet. Pas de localStorage, pas d’IndexedDB, pas de Cache API. Il meurt à la fermeture de l’onglet et au rechargement. Chaque entrée conserve un clone du <body> et un index du <head>, y compris les balises <img>, <link> et <script>. Elle ne conserve pas les octets des images, stylesheets ou scripts : leur réutilisation relève du cache HTTP du browser.
À retenir
Le cache de Turbo est une optimisation de latence perçue, pas une stratégie hors ligne. Une visite de restauration peut afficher un snapshot déjà présent sans requête, même après une coupure réseau, mais ce cache ne survit ni au rechargement ni à la fermeture de l’onglet et ne garantit pas que la page voulue y soit encore. N’en faites pas un mécanisme de persistance.
On lit souvent que le cache est vidé « à chaque soumission de formulaire non-GET ». C’est vrai pour la moitié des cas seulement, et l’autre moitié surprend.
Sous le capot interne
Le vidage est asymétrique. Sur le chemin de succès pleine page,
clearSnapshotCache()n’est appelé que si!formSubmission.isSafe, et seulement à l’intérieur duif (responseHTML): une soumission non-GET dont la réponse n’est pas du HTML ne vide rien (navigator.js#L71-L90). Sur le chemin d’échec pleine page, le cache n’est vidé que siresponseHTMLexiste, y compris pour un formulaireGETqui revient en HTML 4xx ou 5xx (navigator.js#L92-L107). Dans un frame, les callbacks de form submission diffèrent : un succès non-GET et toute réponse HTTP en échec vident le cache, même sans HTML (frame_controller.js#L246-L260). Une network error passe parformSubmissionErrored()et ne le vide pas ; un échec de navigation par link ousrcnon plus.Concrètement, un formulaire de recherche en GET vide tout le cache s’il renvoie un 4xx ou 5xx HTML en pleine page, ou n’importe quel 4xx ou 5xx dans un frame. Rien ne vous le dira.
Reste ce que vous pouvez régler vous-même :
| 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 preview | <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(). Les deux traînent encore dans un paquet de billets, et dans les réponses Stack Overflow, ce site où des humains écrivaient de la documentation les uns pour les autres.
Turbo.config
Depuis 8.0.6, la configuration passe par un objet unique. L’ancienne API de configuration (setProgressBarDelay, setConfirmMethod, setFormMode) existe toujours mais émet 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) => window.confirm(message)
Sous le capot vérifié
Sur ces cinq lignes, deux sont documentées sur la page Drive de la référence :
Turbo.config.drive.progressBarDelayetTurbo.config.forms.confirm.drive.enabled,forms.modeetforms.submittern’apparaissent nulle part surturbo.hotwired.dev. Les trois modes se déduisent des deux comparaisons d’égalité deSession#submissionIsNavigatableplus le défaut"on"; il n’y a pas d’énumération dans le code.Un détail qui va avec, et qui mord :
set submitter(value) { this.#submitter = submitter[value] || value }(config/forms.js#L33-L35). Une chaîne non reconnue est stockée telle quelle, sans erreur, et explose plus tard surconfig.forms.submitter.beforeSubmit(...). Une faute de frappe ne se voit qu’à la première soumission qui possède un submitter ; une soumission déclenchée sans submitter ne passe pas par cet appel.
forms.submitter mérite un mot. Par défaut, Turbo pose disabled sur le submitter pendant la soumission. Le contrôle sort alors du tab order et, s’il l’avait, perd son focus ; selon le browser et l’assistive technology, le focus peut retomber sur <body> et désorienter la personne qui navigue au clavier ou avec un screen reader. Avec "aria-disabled", Turbo pose aria-disabled="true" et intercepte les click sur le submitter, qui reste focusable. Cela bloque un second click traité par Turbo, pas une submission déclenchée directement par du code.
Turbo.config.forms.submitter = "aria-disabled"
Si votre interface doit conserver le focus pendant la submission, cette ligne dans application.js est un meilleur default. Il faut simplement prévoir le style visuel de aria-disabled.
Prefetch et preload
Depuis Turbo 8, le prefetch au survol est actif par défaut. Pour un link éligible, 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 par défaut. La meta turbo-prefetch-cache-time permet de modifier ce TTL en millisecondes. Si le pointer quitte le link avant la fin du délai, la requête ne part pas.
Le prefetch ne s’applique pas à tous les links. Il faut notamment une URL HTTP(S) same-origin et visitable, sans target, download, data-turbo="false", méthode unsafe, stream, confirmation ni attribut UJS ; le link vers la page courante est également exclu. turbo:before-prefetch peut encore annuler. Les autres survols éligibles déclenchent bien 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 ne s’applique qu’aux <a> et qui est scanné après le chargement initial puis après chaque rendu de vue, plutôt qu’au survol. Le mécanisme diffère : le preload alimente le même cache LRU de snapshots que Drive ; seul le prefetch au survol possède son cache séparé d’une entrée pendant dix secondes.
Turbo Frames
Tout tient dans un identifiant
turbo-visit-control, Turbo cherche le frame directement ou via recurse. Sans correspondance, il émet turbo:frame-missing.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. En pratique, laissez le serveur reprendre l’identifiant envoyé par le client avec le helper turbo_frame_request_id de turbo-rails :
<%= turbo_frame_tag turbo_frame_request_id || "invoice_detail" do %>
…
<% end %>
Ce fallback 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 sans un mot toute navigation ultérieure vers ce frame.
L’attribut recurse offre une seconde chance, peu utilisée. Si aucun frame de même id n’est trouvé mais qu’un <turbo-frame src="/fragment" recurse="mon-id"> est présent, Turbo attend son chargement puis va chercher dedans. Le ~= que vous verrez dans le sélecteur interne de Turbo appartient à CSS ; ce n’est pas une syntaxe d’attribut HTML.
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.
L’expiration de session donne un cas classique. La requête part du frame, le serveur redirige vers /login, et la page de login ne contient évidemment pas votre frame. Plutôt que d’intercepter l’événement, marquez 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 listener 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 vient de layout "admin" dans un contrôleur, qui écrase 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 rendu serveur, le transfert et le parsing du layout entier à chaque requête. Le <head> est parsé hors DOM puis jeté, donc ses scripts ne sont pas réévalués. Les scripts du frame extrait sont activés au render, sauf ceux marqués data-turbo-eval="false". Il faut convertir la déclaration en méthode :
layout :layout_for_request
private
def layout_for_request
turbo_frame_request? ? "turbo_rails/frame" : "admin"
end
Piège
Aucune erreur, aucun avertissement, et le frame s’affiche parfaitement. Juste du rendu, des octets et du parsing inutiles à chaque requête. C’est le genre de régression qui n’apparaît qu’en profilant.
Les attributs qui comptent
| Attribut | Effet |
|---|---|
src |
Charge cette URL dans le frame. Dans le helper turbo_frame_tag, la valeur passe par url_for, donc un modèle fonctionne. |
loading="lazy" |
Diffère le premier chargement du src jusqu’à l’entrée dans le viewport. Dans un même FrameController, les changements de src suivants chargent immédiatement. Après une restauration Drive, le controller est recréé et ce flag interne repart à zéro : un nouveau src hors viewport peut attendre à nouveau. |
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 le render d’un frame correspondant, mais aussi avant turbo:frame-missing. Sur un frame connecté, un changement de src ou reload() le retire. La propriété JS frame.complete expose l’état de chargement courant et ne lit pas cet attribut. |
disabled |
Annule le fetch src en cours et empêche le frame d’intercepter de nouvelles navigations. Un link ou un formulaire descendant peut alors retomber sur Drive et naviguer en pleine page ; une form submission déjà partie n’est pas annulée. |
autoscroll |
Appelle scrollIntoView() sur firstElementChild après le render ; un frame vide ne bouge pas. data-autoscroll-block vaut end par défaut et data-autoscroll-behavior, auto. |
refresh="morph" |
Fait morpher le contenu chargé par reload(). Lors d’un page refresh rendu par morph, Turbo conserve un frame src compatible, hors d’une zone data-turbo-permanent, puis appelle automatiquement ce reload. |
Le tableau ne dit pas tout de la façon dont un frame traite une réponse.
Pour une navigation par link ou src, une fois qu’une réponse HTML ordinaire atteint FrameController, le statut HTTP ne choisit pas le renderer du frame. Les callbacks directs requestSucceededWithResponse et requestFailedWithResponse appellent tous deux loadResponse(). Un 4xx ou 5xx avec du HTML non vide et un frame correspondant peut donc être rendu comme un 2xx. Un Content-Type turbo-stream est intercepté plus tôt par StreamObserver. Les autres Content-Types non HTML et les bodies vides ne produisent ni frame render, ni turbo:frame-render, turbo:frame-load ou turbo:frame-missing, même si turbo:before-fetch-response a déjà été émis. Annuler cet événement bloque le chemin de FrameController, avec l’exception stream décrite dans le tableau. Avec turbo-visit-control: reload, Turbo abandonne l’extraction et lance une visite pleine page avec un warning en console ; sans frame correspondant, il suit le chemin turbo:frame-missing décrit plus haut.
Une réponse HTML ordinaire à une form submission suit un autre chemin. Si turbo:before-fetch-response n’est pas annulé, un 4xx ou 5xx donne detail.success === false dans turbo:submit-end, vide le cache de snapshots et appelle loadResponse() sur le frame d’origine. Si le formulaire ciblait un autre frame, cette cible est donc ignorée pour l’échec et Turbo cherche l’id du frame d’origine dans la réponse. Un succès utilise au contraire la cible résolue ; il ne vide le cache que pour une méthode unsafe.
Sous le capot interne
Une précision, parce que « le statut n’est jamais consulté » est légèrement faux.
loadResponselit bien deux propriétés dérivées du statut, mais pour une seule chose, mettre à joursrc.if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { this.sourceURL = fetchResponse.response.url }(
frame_controller.js#L132-L135) Après une navigation par link ou une affectation directe desrc, le frame pointe déjà vers l’URL demandée : si elle répond avec un 404 HTML,reload()rejoue cette URL en erreur. Une form submission ne copie pas son action danssrc. Sur un échec non redirigé, le frame d’origine garde donc son anciensrc, s’il en avait un, etreload()peut revenir à cette URL ; sanssrc, il ne charge rien. Après une redirection,srcprend toujours l’URL finale, même si son statut est un échec.
Une form submission dans un frame n’a pas besoin de rediriger. L’erreur Form responses must redirect to another location n’est déclenchée que pour une submission pleine page en méthode unsafe dont la réponse finale est exactement 200 OK sans redirection. Les submissions GET n’ont pas cette contrainte, et le guard ne vise pas les autres statuts 2xx. Dans un frame, mustRedirect vaut false : un 200 OK avec un frame correspondant est valide.
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 explique une bonne partie des erreurs.
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 qui a fait la requête l’applique. C’est synchrone, ça vit dans le contexte de la requête, current_user existe, le HTML est rendu pour cette personne-là.
Le second est un broadcast : le serveur publie sur un canal Action Cable, tous les onglets abonnés le reçoivent. Les variantes _later_ et la plupart des callbacks de modèle rendent dans un job ; les variantes synchrones, ainsi que certaines suppressions installées par les macros, restent dans le thread appelant. Quand turbo-rails doit rendre le HTML, son renderer synthétique n’a ni session ni Warden exploitable comme une requête normale. Un appel synchrone, perform_now ou l’adapter :inline peut néanmoins encore voir un Current.* thread-local ; un job réellement pris par la queue ne l’hérite pas. Un appel qui reçoit déjà html: ou content: saute ce renderer, et un refresh ne rend aucun HTML. Quand turbo-rails rend le partial, il produit un seul payload HTML, envoyé à l’identique à tous les abonnés. On y revient plus bas, parce que c’est là que se cachent les vrais problèmes.
À retenir
Le format ne dit rien du contexte. Un
<turbo-stream>reçu par un onglet ne porte aucune trace de son origine, et le code d’application est le même dans les deux cas. Tout ce qui distingue les deux mondes s’est joué côté serveur, au rendu, donc au moment où vous décidez quoi mettre dans le<template>.
Frames ou Streams ? Ce qui les sépare vraiment
Les deux savent remplacer un morceau de page, et c’est là que la confusion s’installe.
| Critère | Turbo Frame | Turbo Stream |
|---|---|---|
| Qui désigne la cible | Le client, avant l’envoi | Pour une action ciblée, le serveur, dans la réponse |
| Combien de zones | Une seule, celle du frame | Par <turbo-stream> : zéro ou une avec target, toutes les correspondances avec targets. Une réponse peut contenir plusieurs éléments |
| La mutation est-elle une navigation | Oui : une URL répond avec le frame attendu | Non : le stream décrit une mutation, même si sa requête a une URL |
| Historique et bouton retour | Optionnels, avec data-turbo-action |
Non |
| Lazy loading | Oui, loading="lazy" |
Non |
| Peut-il partir sans requête | Non | Oui, c’est le broadcast |
| Ce qui se passe si la cible manque | Sur le chemin direct, turbo:frame-missing, puis « Content missing » par défaut ; recurse peut encore charger le frame attendu, et turbo-visit-control: reload contourne l’extraction |
Pour une action ciblée, rien du tout, en silence |
| Ce que le serveur doit savoir | L’identifiant attendu, souvent repris depuis l’en-tête Turbo-Frame |
Pour une action ciblée, l’id via target ou le sélecteur CSS via targets. refresh n’a pas de cible |
La dernière ligne est celle qui devrait décider. Sur le chemin direct normal, un frame limite le contrat à un fragment portant le même identifiant : le serveur peut rendre une page complète, mais elle doit contenir l’identifiant demandé. recurse permet de le trouver dans une seconde réponse, tandis que turbo-visit-control: reload abandonne ce contrat pour une visite pleine page. Une action de stream ciblée est plus couplée : le serveur doit savoir adresser ses cibles dans le DOM affiché, sans aucun moyen de vérifier ce contrat. refresh fait exception puisqu’il ne désigne aucune cible.
Si vous avez connu Rails avant 2021, ce premier cas vous rappellera quelque chose. Répondre à une soumission par un document qui décrit des mutations du DOM, c’est exactement ce que faisait le create.js.erb de l’époque UJS. La différence tient au format et au vocabulaire : le serveur renvoie du HTML plutôt qu’une réponse JavaScript applicative, et Turbo fournit huit actions intégrées. Les actions personnalisées étendent ce vocabulaire explicitement, au lieu d’ouvrir la porte à « tout ce que jQuery sait faire ». On gagne surtout des réponses lisibles et une intention bornée. Ce n’est pas une frontière de sécurité : Turbo active explicitement les <script> présents dans les templates de streams, et une faille XSS reste une faille XSS.
Le trajet complet, du controller à la vue
Un controller classique suffit :
# app/controllers/invoices_controller.rb
def create
@invoice = Invoice.new(invoice_params)
if @invoice.save
respond_to do |format|
format.turbo_stream # -> create.turbo_stream.erb
format.html { redirect_to @invoice, status: :see_other }
end
else
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"invoice_form", partial: "invoices/form", locals: { invoice: @invoice }
), status: :unprocessable_entity
end
format.html { render :new, status: :unprocessable_entity }
end
end
end
Le format.turbo_stream nu rend un template ; utilisez-le quand plusieurs zones changent. Le render turbo_stream: inline accepte aussi plusieurs tags concaténés, mais il évite surtout un fichier pour les réponses simples.
Le template est un simple fichier ERB qui appelle le builder :
<%# app/views/invoices/create.turbo_stream.erb %>
<%= turbo_stream.prepend "invoices", @invoice %>
<%= turbo_stream.update "invoices_count", Invoice.count %>
<%= turbo_stream.replace "invoice_form" do %>
<%= render "invoices/form", invoice: Invoice.new %>
<% end %>
Ces trois lignes condensent presque toute l’API, et chacune cache quelque chose.
turbo_stream.prepend "invoices", @invoice ne prend pas de partial : passer un record suffit, le builder appelle to_partial_path et rend invoices/_invoice.html.erb. C’est aussi la raison pour laquelle ce partial doit rendre un élément avec id="<%= dom_id(invoice) %>", sans quoi les helpers basés sur le record ne pourront plus le cibler directement.
turbo_stream.update accepte une chaîne brute comme contenu, ce qui évite un partial d’une ligne pour un compteur.
La forme à bloc capture ce que vous écrivez dans le view context courant. Dans une réponse HTTP, elle voit donc les ivars du controller, y compris celles posées par les before_action. Le contexte synthétique décrit plus haut ne concerne que le rendu d’un broadcast.
Et côté vue appelante, la règle est la même que partout. Ce que le stream vise doit exister avec le bon id.
<%# app/views/invoices/index.html.erb %>
<div id="invoices_count"><%= @invoices.count %></div>
<div id="invoices">
<%= render @invoices %>
</div>
<div id="invoice_form">
<%= render "invoices/form", invoice: Invoice.new %>
</div>
<%# app/views/invoices/_invoice.html.erb %>
<div id="<%= dom_id(invoice) %>" class="invoice">
<%= invoice.reference %>
<%= button_to "Supprimer", invoice, method: :delete, form: { data: { turbo_confirm: "Sûr ?" } } %>
</div>
Notez data-turbo-confirm sur le formulaire du button_to, successeur direct du data-confirm de rails-ujs. La différence est que sa fonction est remplaçable : Turbo.config.forms.confirm accepte votre propre fonction, ce qui permet enfin de remplacer la boîte native par votre propre modale sans réécrire le mécanisme.
Huit actions, et morph n’en est pas une
| Action | Ce qu’elle fait | Cible requise |
|---|---|---|
append |
Ajoute à la fin du contenu de la cible | ✓ |
prepend |
Ajoute au début | ✓ |
before |
Insère avant la cible | ✓ |
after |
Insère après la cible | ✓ |
replace |
Remplace la cible elle-même | ✓ |
update |
Remplace le contenu de la cible | ✓ |
remove |
Supprime la cible. Pas de <template> |
✓ |
refresh |
Lance un refresh Turbo sur l’URL courante, sous forme de visite replace. Pas de <template> |
✕ |
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 handler JavaScript de append l’ignore purement et simplement. refresh lit aussi method, mais pour une autre raison : choisir le mode de rendu de ce refresh, broadcast par broadcast.
La référence documente la déduplication de append et prepend : 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. Depuis 8.0.21, before et after appliquent la même règle aux frères de la cible ; cette extension n’apparaît pas encore dans la référence.
refresh porte aussi un attribut request-id. Il sert à ignorer, dans l’onglet qui l’a émise, une requête récente déjà appliquée ; le debounce client qui fusionne des refresh rapprochés est indépendant de cet identifiant. La section sur les broadcasts détaille les deux mécanismes.
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, cette différence de nature est absorbée pour vous. Si vous passez un record plutôt qu’une chaîne, le helper met le dièse au bon endroit :
turbo_stream.replace @invoice # target="invoice_42" <- dom_id nu
turbo_stream.replace_all @invoice # targets="#invoice_42" <- dom_id avec le #
Le piège n’apparaît donc que le jour où vous écrivez le sélecteur à la main, parce que là plus personne ne le corrige : turbo_stream.replace_all "invoice-row" sans le point ou le dièse ne matche rien, et ne dira rien.
Le silence quand la cible n’existe pas
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 sont 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é.
Debug interne
Le silence total ne vaut que pour une cible présente mais introuvable. Si l’attribut lui-même manque,
get targetElements()lève"target or targets attribute is missing", etget performAction()lève"unknown action"ou"action attribute is missing"(stream_element.js#L95-L120). Ces exceptions sont attrapées par letry/catchdeconnectedCallbacket ressortent enconsole.error(stream_element.js#L33-L41), donc visibles, mais sans événement et sans rien à l’écran.Regardez d’abord la console : si elle reste vide malgré un stream reçu, cherchez une cible absente ; si elle affiche
console.error, cherchez un attribut manquant ou une action inconnue.
Un autre piège, plus fourbe, concerne les requêtes 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, la négociation habituelle retombe sur HTML, sauf si l’URL ou les params imposent explicitement le format turbo_stream.
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 load hook :
# config/initializers/turbo.rb
ActiveSupport.on_load :turbo_streams_tag_builder do
def highlight(target) = action :highlight, target
def highlight_all(targets) = action_all :highlight, targets
end
Côté JavaScript, une entrée dans Turbo.StreamActions, où this est l’élément <turbo-stream> :
Turbo.StreamActions.highlight = 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: :highlight, target: "gantt", html: ""
}
Si l’action n’est pas enregistrée côté JavaScript, l’élément lève unknown action. Turbo est rarement aussi bruyant.
Le statut HTTP, et pourquoi 422
Turbo ne choisit pas le renderer en relisant les en-têtes de la requête. Le delegate du fetch est connu avant son départ : FrameController pour une navigation de frame, Visit ou FormSubmission pour une navigation pleine page. La réponse suit ensuite ce chemin :
- Si le
Content-Typecommence partext/vnd.turbo-stream.html,StreamObserverintercepte la réponse et applique les<turbo-stream>, quel que soit le statut. - Sinon, la réponse revient à ce delegate. L’en-tête
Turbo-Framesert au serveur pour rendre le bon fragment ; Turbo ne l’utilise pas comme aiguillage au retour. - Sur le chemin pleine page, le statut participe alors au choix entre le renderer normal, le renderer d’erreur ou le refus de rendre.
Un formulaire Hotwire qui paraît figé tient souvent à cette ligne de Turbo :
responseSucceededWithoutRedirect(response) {
return response.statusCode == 200 && !response.redirected
}
Pour une soumission pleine page non-GET, si vous répondez 200 avec du HTML 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. Une soumission dans un frame ne passe pas par ce guard.
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 : il exprime sans ambiguïté « cette mutation est terminée, récupère maintenant la ressource cible en GET ». Dans Fetch :
- 303 remplace toute méthode autre que
GETouHEADparGET. - 301 et 302 ne forcent le passage en GET que pour une requête HTTP POST. Une vraie requête HTTP
DELETEredirigée en 302 serait rejouée enDELETEsur l’URL cible. - 307 et 308 préservent toujours la méthode.
Mais turbo-rails 2.0.23 n’envoie pas ces méthodes comme telles. Son listener encodeMethodIntoRequestBody transforme toute méthode non-GET en requête HTTP POST et place la méthode logique dans _method (fetch_requests.js#L1-L18). Cela vaut pour data-turbo-method="delete" comme pour button_to method: :delete. Dans cette configuration, un 302 dégrade donc bien le POST réseau en GET ; il ne rejoue pas DELETE /invoices.
Gardez tout de même status: :see_other. Le 303 documente l’intention, fonctionne avec Turbo sans l’adaptateur Rails et évite que le comportement dépende de l’encodage _method ou d’un autre client HTTP. C’est une convention robuste, pas le correctif d’un DELETE rejoué par turbo-rails.
Une réponse turbo-stream court-circuite le choix du renderer. render turbo_stream: …, status: :unprocessable_entity s’applique donc parfaitement. Le statut reste observable : sur une soumission, turbo:submit-end.detail.success vaut false pour un 422 et true pour un 2xx. Il conserve aussi son sens pour les tests et les clients non-Turbo.
Petit détail amusant, le test porte sur statusCode == 200 exactement. Un 201 Created passe à travers le garde-fou et, si la réponse contient un body HTML non vide, poursuit vers une visite. Sans ce body, Navigator n’en propose aucune.
4xx et 5xx : deux chemins de rendu, pas un
C’est le point sur lequel je m’étais trompé, et il traîne dans beaucoup d’articles. On lit partout qu’un 4xx est rendu par le renderer de page normal et qu’un 5xx passe par le renderer d’erreur. Ce n’est vrai que pour les soumissions de formulaire.
| Ce que vous avez fait | 2xx HTML | 4xx HTML | 5xx HTML |
|---|---|---|---|
| Soumettre un formulaire pleine page | PageRenderer, sauf le 200 non redirigé d’une méthode non-GET, qui est refusé |
PageRenderer |
ErrorRenderer |
Cliquer un lien, ou Turbo.visit() |
PageRenderer |
ErrorRenderer |
ErrorRenderer |
Le tableau suppose une réponse HTML exploitable. Pour une soumission réussie, Navigator ne propose la visite que si responseHTML contient quelque chose ; une réponse vide ou non-HTML ne passe donc pas dans PageRenderer. Sur une visite, une réponse non-HTML prend le chemin d’erreur.
Sous le capot interne
Sur une visite, le test est binaire :
if (isSuccessful(statusCode) && responseHTML != null)rend normalement,elsepart surthis.view.renderError(...)(visit.js#L203-L223), avecisSuccessfuldéfini commestatusCode >= 200 && statusCode < 300(visit.js#L417-L419). La distinction 4xx/5xx n’existe que dansNavigator#formSubmissionFailedWithResponse(navigator.js#L92-L107).
Ça mérite mieux qu’une note de bas de page, parce que ErrorRenderer est nettement plus brutal que PageRenderer :
- Il fait un
replaceChildsur le<head>entier, au lieu de le fusionner. - Il parcourt tous les
<script>du document, y compris ceux du<head>(error_renderer.js#L36-L38), puis réactive ceux qui n’ont pasdata-turbo-eval="false"(util.js#L1-L15). - Il ne fait pas tourner Bardo, la machinerie qui transplante les éléments persistants.
data-turbo-permanentn’est pas honoré sur une page d’erreur.
Et symétriquement, comme un 4xx de formulaire passe par PageRenderer, il est soumis à shouldRender : une divergence data-turbo-track="reload" sur votre page d’erreur transformera l’affichage des erreurs de validation en rechargement complet du navigateur.
Piège
Si votre page d’erreur 500 est un template minimal qui ne charge pas les mêmes assets que le reste du site, alors chaque erreur serveur sur un clic de lien vous rejoue les scripts du
<head>qui n’ont pasdata-turbo-eval="false"et jette vos éléments permanents. Les rapports d’utilisateurs ressemblent alors à « l’appli devient bizarre après une erreur », ce qui est un symptôme atroce à reproduire.
Le morphing
Turbo 8 a introduit les page refreshes avec morphing. L’idée est de comparer l’ancien arbre et le nouveau plutôt que de remplacer le <body>, puis de ne modifier que ce qui diffère. Le scroll peut être conservé ; le focus, la sélection de texte et l’état des transitions survivent quand le même nœud DOM reste en place.
On l’active 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 de page n’a lieu que pour un page refresh. Quand PageView reçoit une Visit, le test exact tient en deux clauses : même pathname (la query string et le fragment ne comptent pas) et action === "replace". Sans Visit, isPageRefresh() renvoie directement true ; une réponse 4xx à une full-page form submission passe notamment par ce chemin et peut morpher si la méthode effective vaut morph.
Sans action explicite, une soumission non-GET ne prend replace que si la réponse a redirigé vers l’URL exacte de la page qui portait le formulaire. Turbo compare l’URL finale à history.location, pas à l’action du formulaire (navigator.js#L158-L166). Depuis /invoices/42, un PATCH /invoices/42 qui redirige vers /invoices/42 morphe. Depuis /invoices, un POST /invoices qui redirige vers /invoices/42 prend advance. Un formulaire GET prend lui aussi advance, sauf si sa réponse est redirigée vers l’URL exacte de la page de départ.
data-turbo-action="replace" sur le formulaire ou son bouton force toutefois cette action. Un formulaire GET qui passe de /search?q=old à /search?q=new peut donc morpher : seul le pathname doit rester identique. La même règle vaut pour un lien data-turbo-action="replace" de /invoices?page=2 vers /invoices?page=3.
Deux déclencheurs courants sont un formulaire qui redirige vers la page dont il est parti et un broadcast <turbo-stream action="refresh">.
Ce que le morphing change, ligne à ligne
Le début et la fin du cycle sont identiques. C’est le milieu qui change, et le milieu est exactement là où vit votre code.
| Ce qui se passe | Rendu classique | Morph |
|---|---|---|
turbo:before-cache |
quand Turbo s’apprête à cacher le snapshot | idem |
| Snapshot mis en cache | selon la politique de la visite | idem |
Le <body> |
remplacé entièrement | comparé nœud à nœud par idiomorph |
disconnect() / connect() Stimulus |
sur tout | sur les nœuds ajoutés, supprimés ou déplacés ; même moveBefore produit les mutations que Stimulus interprète comme une déconnexion puis une reconnexion |
<script> inline déjà présents |
réexécutés sauf avec data-turbo-eval="false" |
✕ jamais |
<script> réellement nouveaux et éligibles à l’évaluation |
exécutés | exécutés ; data-turbo-eval="false" les laisse inertes |
data-turbo-permanent |
transplanté par Bardo, id requis |
nœud courant ignoré s’il porte déjà l’attribut, id non requis, Bardo n’est pas appelé |
| Scroll après un page refresh | préservé avec scroll: :preserve, sinon remis à zéro |
idem |
| Focus et sélection de texte | perdus par défaut ; restaurés dans un élément permanent correspondant | conservés si le même nœud DOM reste en place ; après remplacement, restauration limitée aux <input> et <textarea> portant un id |
autofocus lors d’un page refresh |
honoré | ✕ MorphingPageRenderer.shouldAutofocus vaut false |
turbo:render puis turbo:load |
émis | émis |
Les deux premières lignes dépendent du chemin de visite, pas du mode de rendu. Après une soumission non-GET réussie, Turbo vide le cache puis démarre une visite avec shouldCacheSnapshot: false : pas de snapshot, donc pas de turbo:before-cache. Session#refresh passe aussi ce flag, avec une exception liée au LRU détaillée ci-dessous.
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. C’est asserté par la suite de tests du dépôt lui-même (page_refresh_tests.js#L33-L42).
turbo:before-cache n’est pas une garantie du morphing. On lit, et j’ai écrit, que le morphing le supprime purement et simplement. Le moteur de rendu ne décide de rien. Tout dépend du chemin de visite et de sa politique de snapshot.
Sous le capot interne
Session#refreshforceshouldCacheSnapshot: false(session.js#L108-L117). En l’absence d’un snapshot déjà caché pour l’URL courante, un<turbo-stream action="refresh">n’émet donc pasturbo:before-cache. MaisBrowserAdapter#visitStarted()appelle d’abordloadCachedSnapshot()(browser_adapter.js#L21-L26). Si le LRU contient déjà un snapshot previewable pour cette URL et que le document courant est cacheable, ce chemin rappellecacheSnapshot()sans vérifier le flag et émet l’événement (visit.js#L245-L263,page_view.js#L43-L50). Après une soumission réussie,NavigatorpasseshouldCacheSnapshot: formSubmission.isSafeet vide auparavant le cache pour une méthode non-GET (navigator.js#L71-L87) : un POST qui redirige sur la même URL peut donc morpher sans émettre l’événement. Une visitereplaceordinaire ou un formulaire GET cacheable conserve au contraire le snapshot et l’émet.
Ne branchez donc pas un teardown nécessaire au DOM courant sur turbo:before-cache en supposant qu’il précède tous les morphs. Gardez cet événement pour nettoyer le snapshot quand il y en a un, et utilisez les événements de morph ou le cycle de vie du composant pour protéger le document courant.
Un script nouveau et éligible à l’évaluation peut s’exécuter sous morph. idiomorph matche l’ancien <script> avec le nouveau par nom de balise, puis synchronise leurs nœuds texte par oldNode.nodeValue = newNode.nodeValue. Modifier le nœud texte d’un script déjà exécuté ne le réexécute pas.
Un <script> issu de DOMParser ou d’un <template> est inerte et ne s’exécute pas par simple insertion. Turbo le remplace donc par un élément créé avec document.createElement("script") avant le rendu (util.js#L1-L15). Cette activation a aussi lieu sur le chemin morph, avant qu’idiomorph ne compare les arbres (page_renderer.js#L174-L190). Si le morph retrouve un script déjà présent, le nœud existant reste en place et ne rejoue pas. Si le script est réellement nouveau et ne porte pas data-turbo-eval="false", l’élément activé entre dans le DOM et s’exécute.
idiomorph décide par les identifiants
C’est le mécanisme central du morphing, et celui dont dépend tout le reste. Ce qui survit à un morph, ce qui se rejoue, ce qui casse dans Safari et pas ailleurs, tout part de là.
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é dans l’une des deux racines morphées fait sortir cet id de l’ensemble persistant, dans les deux copies. Pour un page morph, ces racines sont les deux <body> ; pour un stream morph, la recherche reste limitée à la cible et au contenu entrant. Un doublon dans le <head> ou hors de la cible ne pollue donc pas ce morph. En revanche, un doublon accidentel dans la même racine peut dégrader à distance le matching d’un autre élément qui porte cet id.
Sans identifiants stables, le matching se fait 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. Les objets DOM existants restent pourtant en place, et une transition CSS en cours continue si les attributs ou styles qui la déclenchent ne changent pas. Le bug utile à retenir : le state client peut rester attaché au mauvais record, ou être écrasé si idiomorph synchronise la propriété qui le porte.
À retenir
Donnez un
idstable et unique à chaque élément de liste, ne le réutilisez jamais, ne changez jamais la balise associée.dom_id(record)respecte ces trois règles ; sans elles, le morphing d’une liste devient imprévisible.
Les déplacements de nœuds, et pourquoi Safari diffère
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.
Lorsqu’idiomorph réutilise ailleurs un nœud à id persistant, il le déplace sans le cloner. Selon l’ordre du morph, le déplacement se fait directement depuis l’arbre courant ou passe temporairement par la pantry, un <div> caché après <body>. Dans les deux cas, idiomorph utilise moveBefore() lorsqu’il est disponible, avec insertBefore() en fallback.
Le choix entre les deux change le comportement observable :
- Avec
moveBefore(Chromium 133 et plus, Firefox 144 et plus), le nœud reste connecté au niveau du DOM. Les<iframe>ne rechargent pas, l’état des transitions est conservé et lesconnectedCallbacknatifs ne rejouent pas. Stimulus observe malgré tout les mutations de déplacement et exécutedisconnect()puisconnect(). - Avec
insertBefore, le nœud est réellement déconnecté puis reconnecté. Les<iframe>rechargent, les transitions et animations CSS peuvent être interrompues ou relancées, lesconnectedCallbackdes custom elements rejouent, et Stimulus exécute aussidisconnect()puisconnect(). La lecture d’un<video>ou d’un<audio>survit normalement au retrait et à la réinsertion ; ce n’est pas une différence entre les deux chemins.
Et voici la partie que j’ai vue mal comprise plus d’une fois. On présente volontiers moveBefore comme « largement disponible en 2026 ». Ça n’est vrai qu’en excluant Apple.
Sous le capot vérifié
D’après
@mdn/browser-compat-data(relevé du 6 août 2026) : Chromium 133+, Firefox 144+, Opera 118, Samsung Internet 29. Safari, Safari iOS et l’iOS WebView sont tous les trois àversion_added: false, le bug WebKit 281223 étant toujours ouvert.Chrome et Firefox distribués aujourd’hui sur iPhone utilisent eux aussi WebKit, ils prennent donc ce fallback. Apple autorise toutefois des moteurs alternatifs dans l’Union européenne : assimiler durablement tout le trafic iOS à WebKit serait trop large. Safari, les WebViews et les navigateurs WebKit de votre trafic restent concernés, ainsi que les Mac sous Safari.
Donc oui, un bug de morphing peut être parfaitement reproductible sur iPhone et introuvable sur votre poste. Ce n’est pas nécessairement votre code. Et si votre produit est majoritairement consulté sur mobile, ce n’est pas un cas limite : cela peut concerner une part importante de vos utilisateurs, à mesurer dans vos propres statistiques.
La valeur des champs n’est pas protégée
idiomorph ne synchronise pas seulement les attributs, il écrit aussi les valeurs courantes des propriétés DOM 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")
}
}
Le HTML serveur peut contenir un attribut value, les form builders Rails en émettent souvent un, ou ne pas en contenir. idiomorph efface alors la valeur courante dans le second cas, et la remplace par la valeur serveur dans le premier. Dans les deux cas, un refresh qui arrive pendant la frappe peut faire disparaître la saisie non enregistrée. Même traitement pour checked, disabled, <option selected> et le contenu des <textarea>.
Piège
Ce bug se rate facilement en développement dans un seul onglet, mais il n’a rien de spécifique à la production. Un page refresh déclenché pendant la frappe suffit ; un second onglet, un broadcast ou un
Turbo.visit(location.href, { action: "replace" })le reproduit localement. Si vous activez le morphing sur une application où l’on saisit des données, traitez la protection des champs comme un prérequis, pas comme une amélioration.
idiomorph possède une option ignoreActiveValue qui exclut document.activeElement de cette synchronisation. Les renderers intégrés de Turbo ne l’activent pas et n’offrent aucun réglage pour le faire. Turbo exporte toutefois morphElements et morphChildren, qui transmettent cette option à idiomorph ; un renderer custom peut donc l’utiliser.
Sans renderer custom, il reste à protéger le champ vous-même. Le levier le plus direct est data-turbo-permanent, qui fait sauter le morph sur l’élément ; les événements turbo:before-morph-element et turbo:before-morph-attribute permettent aussi des protections plus ciblées. Comme on ne veut pas geler le champ en permanence, on pose l’attribut au focus et on le retire à la sortie. Les fixtures de Turbo montrent ce pattern pour les inputs ; voici la variante étendue aux autres contrôles éditables cités ici :
addEventListener("focusin", ({ target }) => {
const field = target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
? target
: target instanceof HTMLElement && target.isContentEditable
? target.closest("[contenteditable]")
: null
if (field && !field.hasAttribute("data-turbo-permanent")) {
field.toggleAttribute("data-turbo-permanent", true)
const unprotect = ({ relatedTarget }) => {
if (relatedTarget instanceof Node && field.contains(relatedTarget)) return
field.removeEventListener("focusout", unprotect)
field.toggleAttribute("data-turbo-permanent", false)
}
field.addEventListener("focusout", unprotect)
}
})
Tant que le même nœud DOM reste en place, il garde naturellement le focus. Si ce nœud est remplacé, idiomorph ne restaure explicitement le focus et la sélection que pour un <input> ou un <textarea> qui porte un id. Il n’offre aucune garantie équivalente pour un <select>, un contenteditable ou un champ sans id.
Lors d’un page morph, l’autofocus n’est pas honoré : MorphingPageRenderer déclare shouldAutofocus à false. Un frame morph suit un autre chemin ; MorphingFrameRenderer hérite de FrameRenderer, qui applique bien l’autofocus après le rendu.
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 (turbo:morph-element)
document.addEventListener("turbo:morph-element", ({ target }) => { /* … */ })
<%# 4. Le gel complet %>
<div id="map" data-turbo-permanent>…</div>
La documentation ne dit rien de ce qui suit.
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 DOM existant est transplanté dans le nouveau corps par Bardo. Sous morph, c’est un autre code, avec d’autres règles.
Sous le capot interne
Sous morph,
beforeNodeMorphedetbeforeNodeAddeds’occupent dedata-turbo-permanent, avec des critères différents.Le guard « ne me touche pas » (
beforeNodeMorphed) teste l’attribut du nœud courant : pas besoin d’id, le nœud est laissé tel quel s’il porte déjàdata-turbo-permanent. Si seul le HTML entrant ajoute l’attribut, ce premier morph a lieu ; les suivants seront gelés. Le guard debeforeNodeAdded, lui, teste bien l’id, mais avec la polarité inverse :beforeNodeAdded = (node) => !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))(morphing.js#L61-L63). Un élément permanent entrant qui porte uniddéjà présent dans le document est donc refusé : c’est l’existant qui gagne. Sansid, il est inséré normalement.Autre chose, qui explique bien des surprises.
MorphingPageRendereretMorphingFrameRendererréduisent tous les deux Bardo àasync preservingPermanentElements(callback) { return await callback() }. Sous morph, la machinerie de transplantation de Drive ne tourne pas du tout.
Sous morph, un nœud courant qui porte data-turbo-permanent gèle son 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 listener qui écrit event.detail.newElement.matches(…) lèvera une TypeError un jour ou l’autre.
Les broadcasts
Les macros de broadcast tiennent en une ligne. Leurs erreurs, elles, n’apparaissent souvent qu’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 du record |
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 du record. 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.
Le code est intentionnel, mais turbo_stream_from Card ne corrige pas l’asymétrie : la classe donne le stream "Card", pas "cards". Pour qu’un index reçoive les trois opérations, utilisez une macro _to et abonnez la page au même stream. Avec les macros sans _to, il faudrait écouter le stream de collection pour les créations et celui de chaque record pour les mises à jour et suppressions.
À retenir
Deux macros gardent création, mise à jour et suppression sur un même flux :
broadcasts_to :boardpour diffuserappend/replace/remove, etbroadcasts_refreshes_to :boardpour ne diffuser querefresh. Les variantes sans_toenvoient la création à la collection et le reste au stream propre de chaque record. Un index abonné seulement à la collection voit donc les nouvelles lignes apparaître, puis plus rien bouger.
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 soumise au debounce. broadcasts_refreshes_to échappe à ça justement parce qu’elle n’installe qu’un seul after_commit : tout y est asynchrone et passe par le debounce, y compris le destroy.
Le chemin complet d’un refresh
L’onglet qui écrit reçoit son propre broadcast, comme tous les autres. Tout le mécanisme d’identifiant de requête n’existe que pour lui permettre de l’ignorer.
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 réseau et pourrait faire perdre le scroll ou le focus, selon la configuration du refresh et les nœuds conservés par le morph.
Cette protection disparaît, ou agit au mauvais moment, dans les cas suivants :
- Hors requête,
Turbo.current_request_idest nul : c’est unthread_mattr_accessoralimenté par unaround_action. Tous les onglets rafraîchissent. C’est en général ce que vous voulez depuis un job. - Sans en-tête
X-Turbo-Request-Id, par exemple depuis un autre client HTTP, learound_actionn’a rien à recopier. - L’ensemble est borné à 20. Vingt nouvelles requêtes Turbo après celle qui a déclenché le broadcast suffisent à évincer son identifiant. Avec le prefetch au survol actif par défaut, ce n’est plus si théorique.
- À l’inverse,
turbo_stream.refreshrendu en réponse directe porte par défaut l’identifiant de la requête, donc l’onglet demandeur ignore l’action qu’il vient de recevoir. Il faut écrireturbo_stream.refresh(request_id: nil)si cette réponse doit réellement rafraîchir cet onglet.
La variante synchrone broadcast_refresh_to n’est pas une exception, elle passe elle aussi par turbo_stream_refresh_tag, dont le défaut est Turbo.current_request_id (action_helper.rb#L40-L46).
Le debounce, 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 portant la même clé annule le précédent. Mille records modifiés dans une requête et diffusant vers le même flux donnent donc un broadcast ; mille flux GID distincts restent mille broadcasts.
Côté client, Session#refresh ajoute un second debounce de 150 ms.
Le revers :
Piège
Un processus qui sort avant la fin du délai perd le broadcast. Un
rails runner, une tâche rake ou un conteneur peut très bien l’envoyer s’il reste vivant plus de 0,5 s. S’il termine avant le déclenchement de laScheduledTask, il n’y a ni erreur ni log. Pour un processus one-shot, la variante synchronebroadcast_refresh_toévite ce timing. Ajouter unsleep Turbo::Debouncer::DEFAULT_DELAY + 0.1reste un workaround, pas un contrat documenté.Ce bug traverse facilement la revue de code : le script de migration marche, les données sont bonnes, et personne ne remarque que les onglets ouverts n’ont pas bougé.
Dans les tests Rails qui chargent ActiveSupport::TestCase, il n’y a pas de debounce. L’initializer de turbo-rails remplace alors le debouncer par Turbo::ImmediateDebouncer. Pour N appels portant la même clé, vos assertions comptent N broadcasts là où la production n’en verra qu’un. Un test harness qui ne charge pas ActiveSupport::TestCase ne bénéficie pas automatiquement de ce remplacement.
current_user n’est pas disponible comme dans une requête
Tout broadcast qui doit rendre son propre HTML, synchrone ou asynchrone, passe par :
ApplicationController.render(formats: [format], **rendering)
broadcast_refresh_later_to, ainsi que les appels qui reçoivent déjà html: ou content:, n’ont rien à rendre et ne passent pas par ce renderer. Pour les autres, il s’agit d’un ActionController::Renderer avec un environnement Rack synthétique. Il n’y a ni session, ni cookies, ni clé Warden. Conséquences mécaniques :
- Avec Devise standard,
current_userlèveDevise::MissingWarden. Le helper appellewarden, absent de l’environnement synthétique. Un partial qui contientif current_user.admin?fait donc échouer le rendu du broadcast ; il ne rend pas silencieusement une branche anonyme. Un helper applicatif personnalisé peut avoir un autre comportement, mais il ne faut pas en dépendre ici. Current.*n’est pas transmis à un worker asynchrone. Un job réellement pris par la queue s’exécute dans un autre contexte, sans le state de la requête.perform_nowet l’adapter:inlinepeuvent rester dans le même thread et donner l’impression inverse ; les variantes de broadcast synchrones rendent elles aussi dans le thread courant. Ne prenez pas ces chemins comme preuve qu’un worker verraCurrent.*.ApplicationControllerest codé en dur, pas dépouillé de toute sa configuration. Le renderer conserve les view paths configurés et, avec le réglage Rails par défautinclude_all_helpers, les helpers applicatifs. En revanche, aucunbefore_actionne tourne et aucune ivar de requête n’est préparée. Unhelper_methodqui dépend du state de l’instance du controller reste donc inutilisable.- Le résultat des helpers
_urldépend dedefault_url_optionset dudefault_envdu renderer. Avec ce renderer Rails 8.0.2, l’absence de host configuré produit une URL synthétique surexample.org. Ne dépendez pas de ce fallback : configurezRails.application.routes.default_url_options[:host]pour les broadcasts. Les helpers_path, eux, n’ont pas besoin de host (renderer.rb, v8.0.2).
Le pattern qui passe à l’échelle est le seul qui accepte que le HTML diffusé soit le même pour tous les destinataires : diffuser un partial neutre, et faire charger les parties personnalisées par un frame, que chaque navigateur ira chercher avec ses propres cookies.
<%# le partial 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 sont de diffuser un flux par utilisateur, ce qui est correct mais en O(utilisateurs), ou de passer explicitement tout ce dont vous avez besoin par locals: et traiter « ce partial est diffusable » comme une propriété stricte du partial.
C’est aussi une raison pratique de préférer broadcasts_refreshes. Un refresh ne rend rien côté serveur. Chaque navigateur refait sa propre requête, avec sa propre session, et 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é. Décoder le payload situé avant -- révèle ici la string JSON "quotes". Avec un record, cette string contient son to_gid_param, lui-même décodable en gid://app/Account/5. Aucun de ces décodages ne demande votre clé.
Et Turbo::StreamsChannel#subscribed accepte l’abonnement sans aucune vérification supplémentaire. Il vérifie que la signature est valide, point.
À retenir
Signer n’est pas autoriser. Un
signed-stream-nameprouve que le nom du flux vient bien de votre application ; il ne prouve rien sur la personne qui s’y abonne, et il n’expire pas par défaut. Porter le tenant dans le nom du flux cloisonne les diffusions accidentelles. Cela n’autorise pas l’abonné : si l’accès doit pouvoir être révoqué, le canal doit contrôler l’utilisateur courant au moment de l’abonnement.
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 %>
Ce cloisonnement ne remplace pas une autorisation. Pour une appartenance révocable, écrivez votre propre canal et faites le contrôle avant stream_from. Le scénario de réabonnement avec un ancien nom signé n’est exploitable que si la connexion Action Cable de cette personne est encore acceptée ; le contrôle du canal ferme cette porte.
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. Les retry policies, callbacks et queue_as définis uniquement sur ApplicationJob ne s’appliquent donc pas. Les trois classes déclarent discard_on ActiveJob::DeserializationError, mais elles ne transportent pas les mêmes arguments. Avec ActionBroadcastJob et BroadcastJob, un record présent dans les rendering options puis supprimé avant le perform fait abandonner le job sans retry ; les helpers d’instance ajoutent justement self aux locals:. Le stream et la target sont déjà convertis en chaînes avant l’enqueue. BroadcastStreamJob, utilisé pour un refresh, ne reçoit que le nom du stream et le <turbo-stream> déjà rendu, tous deux sous forme de chaînes. Rails ne repropage pas une erreur de désérialisation traitée par discard_on, mais écrit un log au niveau error et émet discard.active_job.
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 microtask qui suit chaque modification.
Sur une visite classique qui conserve un snapshot, turbo:before-cache lance la capture, mais le clone n’est pas immédiat : PageView#cacheSnapshot attend le prochain event loop tick, et la visite n’attend pas cette Promise avant de poursuivre le rendu. L’échange de <body> et la microtask du MutationObserver peuvent donc faire tourner disconnect() avant le clone. Un cleanup synchrone dans disconnect() peut nettoyer aussi la copie différée ; l’ordre n’en fait toutefois pas un contrat. turbo:before-cache reste le hook public et déterministe pour préparer le snapshot.
Sous morph, il n’y a pas de disconnect/connect pour les éléments simplement modifiés en place. Stimulus exécute ces callbacks pour un ajout, une suppression ou un déplacement. Ce dernier peut être direct ou passer par la pantry, y compris avec moveBefore. Changer la valeur de data-controller les déclenche aussi. turbo:before-cache, lui, dépend du chemin de visite comme vu plus haut : absent après une soumission non-GET réussie ; absent pour Session#refresh, sauf si le LRU contient déjà un snapshot previewable de l’URL courante et que le document courant est cacheable ; présent pour une visite ordinaire qui conserve son snapshot.
Une fixture de test de Turbo 8.0.23 force la reconnexion ainsi :
addEventListener("turbo:morph-element", ({ target }) => {
for (const { element, context } of application.controllers) {
if (element === target) {
context.disconnect()
context.connect()
}
}
})
Ce n’est pas une API de reconnexion promise par Turbo : la fixture appelle directement le context de Stimulus et reste liée à cette version. À chaque turbo:morph-element, elle parcourt toute la collection application.controllers, mais elle ne reconnecte que les contrôleurs dont l’élément est exactement target. Sur une grande page, le coût vient donc du scan répété, pas d’une reconnexion générale.
Ce qui casse, et pourquoi
À retenir
Toute bibliothèque qui injecte du DOM absent du HTML serveur, ou qui écrit
classoustyleau runtime, peut entrer en conflit avec le morphing. La casse dépend de ce que le serveur renvoie et de la capacité de la bibliothèque à réconcilier ou reconstruire son state. Ce n’est pas une incompatibilité automatique, mais cette surface demande une protection ou un lifecycle explicite.
| Bibliothèque | Ce qui casse | La cause | La parade |
|---|---|---|---|
| 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. Ce comportement n’est pas celui de Chart.js utilisé directement. Le <script> qui recréerait le graphique 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 |
Rendre côté serveur un wrapper stable qui contient le contrôle et le DOM injecté, puis poser id et data-turbo-permanent sur ce parent. Sinon, teardown avant son morph et réinitialisation après |
| Leaflet, Mapbox GL | Carte morte, tuiles grises, ou Map container is already initialized |
Le DOM interne injecté par la bibliothèque est supprimé, tandis que le conteneur survit ; disconnect() ne tourne donc pas |
Détruire avant le morph du conteneur, réinitialiser sur turbo:morph-element, puis appeler invalidateSize() avec Leaflet ou resize() avec Mapbox GL |
<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, ou l’UI croit le câble hors ligne | Un reparenting via le fallback insertBefore le désabonne ; un morph en place peut retirer l’attribut runtime connected sans reconnecter l’élément |
id stable, hors des zones réordonnées, et protection de l’attribut connected ou data-turbo-permanent |
Trix et Action Text sont corrigés depuis mars 2025, contrairement à ce qu’affirment encore beaucoup de billets. La technique employée donne un pattern utile pour un custom element 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 custom element auto-réparant. Si vous écrivez le vôtre, faites ça.
Ce qui fuit si vous ne nettoyez pas
Avec Turbo, le même document et son contexte JavaScript peuvent rester actifs longtemps. Toute ressource possédée par une instance de controller doit avoir son teardown dans disconnect() : 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. Une ressource réellement partagée ou singleton suit un autre lifecycle, souvent avec un comptage de références ; chaque disconnect() ne doit évidemment pas la détruire.
Le cas des contextes WebGL vaut d’être cité. Leur quota dépend du navigateur, du GPU et de l’implémentation. Si vous laissez fuiter des cartes Mapbox, le navigateur peut perdre un ou plusieurs contextes ou refuser d’en créer un nouveau ; les canvas concernés peuvent alors devenir blancs ou rester inutilisables jusqu’à leur réinitialisation.
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), un optimistic update construit 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 bubble jusqu’à document, y compris à travers les shadow roots, avec detail.request et detail.error. Un listener 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 quitte la page. L’échec réseau est enregistré comme SystemStatusCode.networkFailure, l’adaptateur émet turbo:reload avec reason: "request_failed", puis fait une navigation classique. Hors ligne, cela veut dire la page d’erreur réseau du navigateur, avec tout le contexte JavaScript de l’application perdu. Vous perdez l’état client alors que le réseau vient déjà de tomber.
Sous le capot interne
Le nom
reloadinduit en erreur, et j’ai mis du temps à comprendre ce qui se passait vraiment :reload(reason) { dispatch("turbo:reload", { detail: reason }) window.location.href = (this.redirectedToLocation || this.location)?.toString() || window.location.href }(
browser_adapter.js#L130-L134) Ce n’est pas unlocation.reload(). C’est une navigation pleine page vers la destination de la visite, celle qui vient d’échouer. Sur un clic de lien hors ligne, vous n’atterrissez donc pas sur la page où vous étiez : vous atterrissez sur la page d’erreur réseau à l’adresse du lien, et l’état de la page précédente est perdu.
turbo:reloadest en outre dispatché sanscancelable, donc l’écouter ne permet d’empêcher quoi que ce soit. Sondetailest l’objet raison complet :event.detail.reasonvaut"request_failed", etevent.detail.context.statusCodeporte le code interne.
D’où le seul point d’interception qui fonctionne, turbo:fetch-request-error, plus tôt dans la chaîne. Le preventDefault() n’y est pas cosmétique, il fait retourner false à #willDelegateErrorHandling, ce qui court-circuite requestErrored, donc recordResponse, donc l’adaptateur n’est jamais sollicité et la navigation n’a pas lieu. En contrepartie, FetchRequest#perform rethrow quand même l’erreur : attendez-vous à une unhandled promise rejection dans la console.
document.addEventListener("turbo:fetch-request-error", (event) => {
event.preventDefault() // empêche aussi le rechargement complet sur une visite Drive
showOfflineBanner(event.detail.error)
})
window.addEventListener("offline", () => showOfflineBanner())
window.addEventListener("online", () => hideOfflineBanner())
Attention à l’ordre des événements : 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, car 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 isDocumentNavigation = ({ request }) => {
const accept = request.headers.get('Accept') || ''
return request.method === 'GET' && (
request.mode === 'navigate' || // navigation navigateur
(request.destination === '' && // fetch() de Turbo Drive
!request.headers.has('Turbo-Frame') && // pas une requête de frame
!accept.includes('text/vnd.turbo-stream.html') && // pas un GET stream
accept.includes('text/html'))
)
}
registerRoute(isDocumentNavigation, new NetworkFirst())
registerRoute(
({ request }) => ['style', 'script', 'image', 'font'].includes(request.destination),
new CacheFirst()
)
Les deux exclusions sont indispensables : Frames et Streams utilisent eux aussi fetch(), portent destination === "" et acceptent du HTML. Sans elles, une réponse partielle peut polluer le cache de la même URL utilisée ensuite par Drive. La seconde route, elle, fonctionne telle quelle : ces requêtes sont bien initiées par le navigateur et portent un destination renseigné.
Ce matcher suppose aussi que vos autres fetch() applicatifs en GET ne demandent pas de HTML sans en-tête distinctif. Si c’est votre cas, ces métadonnées HTTP ne suffisent pas à reconnaître Drive : marquez vos requêtes Drive d’un en-tête applicatif depuis turbo:before-fetch-request et faites porter le prédicat dessus.
Deux pannes de service worker reviennent souvent avec Turbo : un mauvais Content-Type et des asset fingerprints incohérents. Ce ne sont pas les deux seules possibles.
Servir une réponse au mauvais Content-Type. Sur une visite Drive, Turbo enregistre contentTypeMismatch, émet turbo:reload puis fait une navigation pleine page vers la destination. Dans un frame, une réponse non HTML laisse le frame inchangé après les événements de fetch. Ce n’est silencieux que dans ce second contexte côté interface.
Servir un HTML dont les asset fingerprints ne correspondent plus. Turbo compare les éléments data-turbo-track="reload" entre snapshots. En cas de divergence, il déclenche un rechargement complet du navigateur. Une boucle n’apparaît que si les réponses suivantes continuent d’alterner entre des HTML et des assets incompatibles ; une entrée de cache périmée ne suffit pas, à elle seule, à garantir la boucle.
Rails 8 ajoute ses propres contraintes :
- Placez le service worker à une URL racine stable. Par défaut, son scope ne peut pas remonter au-dessus du répertoire de son URL. Le header HTTP
Service-Worker-Allowedpeut élargir ce scope, mais il ne règle pas l’URL instable d’un asset fingerprinté par Propshaft. Deux options simples :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 controllerrails/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. UtilisezimportScripts()dans un classic worker, ou enregistrez un module worker avec{ type: "module" }et des imports statiques résolus comme de vraies URL de modules, sans importmap. - Les URL d’assets fingerprintés changent avec leur contenu. Un precache manifest écrit à la main périme au premier déploiement qui touche un fichier. Préférez le runtime caching, 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
Ce risque de production reste peu documenté dans 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 reste périmée jusqu’à ce qu’une navigation, un catch-up refresh ou un payload ultérieur la resynchronise. Il n’y a ni replay automatique ni indication d’erreur.
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.
La surface publique d’observation dans le DOM tient dans un attribut : <turbo-cable-stream-source> pose et retire connected. On peut instrumenter les callbacks de subscription ou le consumer Action Cable, mais au prix d’un couplage à leurs internals.
Sous le capot vérifié
L’élément dispatche bien un événement, mais pas celui qu’on voudrait : un
MessageEvent("message")par payload reçu, qui est le canal par lequelStreamObserverle consomme (cable_stream_source_element.js#L30-L33). Ce qu’il ne dispatche pas, c’est le moindre événement de cycle de vie :subscriptionConnectedetsubscriptionDisconnectedse contentent de poser et retirer l’attribut.Pour rester sur l’interface publique du custom element, observez donc cet attribut avec un
MutationObserverou, pour un simple bandeau, avec du CSS :turbo-cable-stream-source:not([connected]) ~ .offline-banner { display: block }.
D’où la parade, qui est entièrement du code applicatif. connected est un attribut posé à l’exécution et absent du HTML serveur. Un morph en place peut donc le retirer sans reconnecter l’élément, donnant un faux état hors ligne. Protégez cet attribut, ou rendez la source permanente avec un id stable, avant de l’observer :
document.addEventListener("turbo:before-morph-attribute", (event) => {
if (event.target.matches("turbo-cable-stream-source") &&
event.detail.attributeName === "connected") {
event.preventDefault()
}
})
// contrôleur Stimulus posé sur <turbo-cable-stream-source>
connect() {
this.observer = new MutationObserver(() => {
const isConnected = this.element.hasAttribute("connected")
if (isConnected && this.wasDisconnected) {
Turbo.visit(location.href, { action: "replace" })
}
this.wasDisconnected = !isConnected
})
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. Le focus survit si son nœud reste en place ; idiomorph peut aussi le restaurer sur un <input> ou un <textarea> identifié. C’est ce qui rend le couple morphing + broadcasts praticable : le rattrapage devient assez bon marché pour qu’on l’écrive.
Dernière chose à savoir dans le même registre. Session#refresh abandonne aussi, sans retry, un refresh qui arrive pendant qu’une navigation est déjà en cours (!this.navigator.currentVisit). C’est un second chemin, plus étroit, vers la même péremption.
Checklist de debug
Cette checklist résout presque tous les bugs Turbo que j’ai rencontrés. Elle est volontairement mécanique : chaque étape élimine une classe de causes, et les trois premières prennent dix secondes.
1. La console, avant le réseau. Un console.error change complètement le diagnostic. Form responses must redirect → statut HTTP. unknown action ou target or targets attribute is missing → le <turbo-stream> est mal formé. Content missing → identifiant de frame. Console vide et rien à l’écran : cherchez d’abord un no-op silencieux, une target absente ou un événement annulé. Cela oriente le diagnostic, mais ne prouve pas que la réponse était vide.
2. La requête est-elle partie, et avec quels en-têtes. Dans l’onglet Réseau, vérifiez Accept, Turbo-Frame et X-Turbo-Request-Id. L’absence de text/vnd.turbo-stream.html dans Accept empêche la négociation habituelle du format, mais une extension .turbo_stream ou un params[:format] explicite peut encore le sélectionner. Regardez donc aussi l’URL et les params. Si la requête n’est pas partie du tout, cherchez un data-turbo="false" sur un ancêtre.
3. Le statut, et seulement à ce moment-là. 200 sans redirection sur un POST est le classique. Un Content-Type turbo-stream choisit le stream renderer avant que Turbo consulte les page/frame renderers, mais le statut alimente toujours turbo:submit-end.detail.success. Pour une navigation de frame lancée par link ou src, 2xx et 4xx/5xx HTML passent tous par loadResponse(). Pour une réponse HTML ordinaire à une frame form, le statut choisit la branche success ou failure. Il détermine aussi le frame qui reçoit la réponse et le vidage éventuel du cache Drive.
4. La cible existe-t-elle vraiment, à cet instant. Dans la console, document.getElementById("votre_id"). Vérifiez aussi qu’elle n’est pas dans un <template>, dans une <iframe>, ou dans un frame loading="lazy" pas encore chargé : trois endroits où Turbo ne regardera pas.
5. Combien y en a-t-il ? document.querySelectorAll("#votre_id").length. La réponse doit être 1. 2 explique à la fois les streams qui visent le mauvais élément et les morphs qui se comportent bizarrement à distance.
6. Quel renderer a tourné. document.addEventListener("turbo:before-render", e => console.log(e.detail.renderMethod)). MorphingPageRenderer exige une méthode effective morph et un page refresh au sens de PageView. Quand il existe une Visit, cela signifie action: "replace" et le même pathname que la dernière page rendue ; sans Visit, isPageRefresh() vaut directement true. Une soumission pleine page réussie crée une Visit, tandis qu’une réponse en échec prend le chemin de rendu direct. Un <%= yield :head %> manquant fait disparaître la meta morph, mais ce n’est qu’une des causes possibles.
7. Est-ce que ça vient d’un broadcast. Regardez si le HTML reçu est le même pour tout le monde. Avec Devise standard, un partial diffusé qui appelle current_user échoue faute de Warden ; un helper personnalisé peut produire un autre résultat, tout aussi dépendant d’un contexte absent. Rendez un HTML neutre ou passez des locals explicites.
8. Le même test dans Safari, ou sur un iPhone. Une différence limitée à ces navigateurs justifie de vérifier Element.prototype.moveBefore et le fallback insertBefore d’idiomorph. Ce n’est pas une preuve : le code applicatif, le CSS et d’autres écarts d’API restent des suspects. Réduisez le cas avant d’innocenter l’application.
9. Coupez le réseau, et regardez ce qui se passe. C’est le seul moyen de découvrir qu’un clic de lien hors ligne quitte votre application, et que personne n’écoute turbo:fetch-request-error.
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 met à jour une zone inattendue | Il hérite du frame qui le contient, de son target, ou d’un data-turbo-frame sur le formulaire ou le bouton |
| 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 |
Un frame reste inchangé après les événements de fetch ; une visite Drive émet turbo:reload puis navigue en pleine page vers la destination |
| 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. turbo:before-cache dépend en plus du chemin : absent après une soumission non-GET réussie ; absent sur Session#refresh, sauf si un snapshot previewable de l’URL courante existe déjà et que le document courant est cacheable |
| Le morphing « ne marche pas », la page se remplace | Sur un page refresh, la méthode effective n’est pas morph. Vérifier la meta émise par turbo_refreshes_with, donc <%= yield :head %>, ou l’attribut method du stream refresh |
| Le morphing ne se déclenche pas après un formulaire | Pour une réponse pleine page réussie transformée en Visit, il faut une méthode effective morph, action: "replace" et le même pathname. Sans action explicite, Turbo ne choisit replace que pour une redirection vers l’URL complète de départ ; sinon il choisit advance. Un 4xx pleine page passe directement par renderPage, un 5xx par ErrorRenderer, et une soumission de frame suit le renderer du frame : ces branches n’appliquent pas ce test de Visit |
| Rechargement complet du navigateur à chaque navigation | Divergence de signature data-turbo-track="reload". Normal après un déploiement. Une boucle demande que les réponses successives restent incohérentes ; chercher un asset injecté dynamiquement ou un cache de service worker qui mélange les versions |
| Les listes s’animent n’importe comment sous morph | Pas d’id stable sur les éléments, ou un id dupliqué dans l’une des racines de ce morph, ou la balise a changé |
| Ça marche sur votre poste, ça casse sur iPhone, sous morph | Vérifier la présence de Element.prototype.moveBefore. Le fallback insertBefore peut déconnecter puis reconnecter les nœuds déplacés ; les navigateurs WebKit partagent souvent ce chemin, mais ce seul symptôme ne prouve ni la cause ni l’innocence du code applicatif |
| Le texte que l’utilisateur tape est effacé | syncInputValue écrit les valeurs courantes des propriétés DOM, et Turbo n’active pas ignoreActiveValue |
| Le bouton retour affiche un contenu périmé | Avec un snapshot de restauration utilisable, Turbo rend ce snapshot une seule fois et ne lance aucun fetch. Sans snapshot utilisable, il repart sur le réseau. turbo-cache-control: no-cache si le contenu est sensible |
| Le bouton retour affiche un widget en double | Le snapshot a gardé le DOM injecté. Le nettoyer dans turbo:before-cache, le hook déterministe prévu pour cela. Un disconnect() synchrone peut aussi précéder le clone différé, mais ne doit pas être votre seul contrat de cache |
| 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é ou canal sans autorisation. Signer n’est pas autoriser |
| Un broadcast arrive deux fois chez l’auteur | request_id absent ou nul, broadcast hors requête, ou identifiant évincé après vingt nouvelles requêtes Turbo |
| 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, après l’avoir protégé du morph |
| 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 navigue vers la destination, hors Turbo. preventDefault() sur turbo:fetch-request-error est le seul moyen de l’empêcher : turbo:reload n’est pas annulable |
Après le 404 d’un formulaire, le frame affiche l’erreur mais reload() revient à l’ancien contenu |
Une réponse de formulaire en échec ne remplace pas un src déjà présent. Une navigation lancée par un lien ou par src, elle, a fixé cette URL avant la requête : reload() rejoue alors l’URL en erreur |
| Après une erreur 500 sur un clic de lien, l’application devient instable | ErrorRenderer remplace tout le <head>, réactive les scripts sauf avec data-turbo-eval="false" et ne fait pas tourner Bardo : data-turbo-permanent n’est pas honoré |
| Le retour arrière est lent depuis qu’un formulaire de recherche existe | Un échec GET avec réponse HTML vide le cache Drive ; dans un frame, tout échec de formulaire le vide |
Certaines modifications ne sont pas diffusées depuis un rails runner |
Le processus est sorti avant la tâche de debounce programmée à 0,5 s. S’il reste vivant au-delà du délai, le broadcast part |
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, tous à ranger sous l’étiquette vérifié : 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, la réassignation de event.detail.url sur turbo:before-fetch-request, et le fait que turbo:frame-render soit déclaré annulable alors que l’annuler ne produit aucun effet.
Les détails internes sont reliés au code ou aux tests des versions épinglées ; relisez ces sources à chaque montée de Turbo.
Gardez surtout la règle simple : Drive tant qu’une navigation suffit, Frame pour un fragment adressable, Stream quand le serveur doit orchestrer plusieurs cibles. Le cache, le morphing et les broadcasts ne changent pas ce périmètre ; ils changent le trajet ou le rendu.
Si votre application Hotwire accumule ces symptômes, c’est mon travail chez SXN Labs. Je pars d’un cas reproductible, corrige le contrat rompu et vérifie le résultat en production.