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

Trois panneaux comparant Turbo Drive qui remplace la page entière, Turbo Frames qui remplace un fragment désigné par le client avant l'envoi de la requête, et Turbo Streams qui applique des ordres à des cibles nommées par le serveur dans la réponse.
La même mécanique, trois scopes. Ce qui change d'une colonne à l'autre : l'étendue du remplacement, qui en désigne la cible, et à quel moment.

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 émet turbo:frame-missing puis, 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: reload contourne 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-request fait 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 ce this.url qui part au fetch juste après. Réécrire event.detail.url depuis 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 await de la pause, donc modifier detail.url après un preventDefault() et avant resume() n’a aucun effet. Et affectez un objet URL, pas une chaîne : Turbo lit this.url.href, une chaîne donne undefined. 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() quand this.action == "restore", et this.willRender sinon (visit.js#L377-L383). Or loadCachedSnapshot calcule sa preview par const 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 du if (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 si responseHTML existe, y compris pour un formulaire GET qui 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 par formSubmissionErrored() et ne le vide pas ; un échec de navigation par link ou src non 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.progressBarDelay et Turbo.config.forms.confirm. drive.enabled, forms.mode et forms.submitter n’apparaissent nulle part sur turbo.hotwired.dev. Les trois modes se déduisent des deux comparaisons d’égalité de Session#submissionIsNavigatable plus 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 sur config.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

La requête porte l'en-tête Turbo-Frame. La réponse peut être n'importe quel HTML : Turbo applique d'abord turbo-visit-control, puis cherche le frame directement ou via recurse. Sans correspondance, il émet turbo:frame-missing.
Après 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 :

  1. complete est posé sur le frame, avant l’événement.
  2. turbo:frame-missing est émis sur le frame, annulable, avec detail.response (une Response brute) et detail.visit(urlOuResponse, options).
  3. Si personne n’annule, le frame affiche <strong class="turbo-frame-error">Content missing</strong> et Turbo lève une TurboFrameMissingError.

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. loadResponse lit bien deux propriétés dérivées du statut, mais pour une seule chose, mettre à jour src.

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 de src, 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 dans src. Sur un échec non redirigé, le frame d’origine garde donc son ancien src, s’il en avait un, et reload() peut revenir à cette URL ; sans src, il ne charge rien. Après une redirection, src prend 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.

Deux colonnes comparant une réponse HTTP turbo-stream, rendue dans le cycle de la requête avec session et current_user, et un broadcast différé rendu hors requête, où Devise lève MissingWarden si le partial appelle current_user. Les deux convergent vers le même élément turbo-stream.
Le format est identique et l'onglet ne peut pas les distinguer. Toute la différence s'est jouée avant, au moment du rendu côté serveur.

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", et get performAction() lève "unknown action" ou "action attribute is missing" (stream_element.js#L95-L120). Ces exceptions sont attrapées par le try/catch de connectedCallback et ressortent en console.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 :

  1. Si le Content-Type commence par text/vnd.turbo-stream.html, StreamObserver intercepte la réponse et applique les <turbo-stream>, quel que soit le statut.
  2. Sinon, la réponse revient à ce delegate. L’en-tête Turbo-Frame sert au serveur pour rendre le bon fragment ; Turbo ne l’utilise pas comme aiguillage au retour.
  3. 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 :

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, else part sur this.view.renderError(...) (visit.js#L203-L223), avec isSuccessful défini comme statusCode >= 200 && statusCode < 300 (visit.js#L417-L419). La distinction 4xx/5xx n’existe que dans Navigator#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 :

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 pas data-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#refresh force shouldCacheSnapshot: 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 pas turbo:before-cache. Mais BrowserAdapter#visitStarted() appelle d’abord loadCachedSnapshot() (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 rappelle cacheSnapshot() 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, Navigator passe shouldCacheSnapshot: formSubmission.isSafe et 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 visite replace ordinaire 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à.

Deux colonnes montrant la même insertion en tête de liste : avec des id stables, un nœud est inséré et les autres restent intacts ; sans id, le matching se fait par position et chaque ligne est réécrite. En dessous, les trois conditions pour qu'un id compte, et la bifurcation moveBefore contre insertBefore.
Le contenu et l'ordre visuel sont identiques dans les deux colonnes ; le nouveau HTML ne l'est pas. Ce qui change, c'est ce qui survit à l'opération.

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 id stable 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 :

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, beforeNodeMorphed et beforeNodeAdded s’occupent de data-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 de beforeNodeAdded, 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 un id déjà présent dans le document est donc refusé : c’est l’existant qui gagne. Sans id, il est inséré normalement.

Autre chose, qui explique bien des surprises. MorphingPageRenderer et MorphingFrameRenderer ré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 :board pour diffuser append / replace / remove, et broadcasts_refreshes_to :board pour ne diffuser que refresh. Les variantes sans _to envoient 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 :

  1. Hors requête, Turbo.current_request_id est nul : c’est un thread_mattr_accessor alimenté par un around_action. Tous les onglets rafraîchissent. C’est en général ce que vous voulez depuis un job.
  2. Sans en-tête X-Turbo-Request-Id, par exemple depuis un autre client HTTP, le around_action n’a rien à recopier.
  3. 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.
  4. À l’inverse, turbo_stream.refresh rendu 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 écrire turbo_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 la ScheduledTask, il n’y a ni erreur ni log. Pour un processus one-shot, la variante synchrone broadcast_refresh_to évite ce timing. Ajouter un sleep Turbo::Debouncer::DEFAULT_DELAY + 0.1 reste 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 :

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-name prouve 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 class ou style au 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 reload induit 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 un location.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:reload est en outre dispatché sans cancelable, donc l’écouter ne permet d’empêcher quoi que ce soit. Son detail est l’objet raison complet : event.detail.reason vaut "request_failed", et event.detail.context.statusCode porte 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 :

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 lequel StreamObserver le consomme (cable_stream_source_element.js#L30-L33). Ce qu’il ne dispatche pas, c’est le moindre événement de cycle de vie : subscriptionConnected et subscriptionDisconnected se contentent de poser et retirer l’attribut.

Pour rester sur l’interface publique du custom element, observez donc cet attribut avec un MutationObserver ou, 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 :

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.