---
title: Turbo n'a pas trois outils, il a trois portées
description: 'Drive, Frames, Streams et morphing expliqués par ce qui les relie :
  qui désigne la cible du remplacement. Avec les pièges que la documentation officielle
  ne mentionne pas, et un index par symptôme.'
url: https://sxnlabs.com/ruby/2026/08/06/turbo-drive-frames-streams-reference/
lang: fr
date: '2026-08-06T09:00:00+02:00'
tags:
- Hotwire
- Turbo
- Rails
- Morphing
- Stimulus
---

# Turbo n'a pas trois outils, il a trois portées

La documentation de Turbo a la réputation d'être incomplète. Elle ne l'est pas vraiment : chaque page dit à peu près ce qu'elle doit dire. Le problème est ailleurs. Drive, Frames et Streams y sont documentés côte à côte, comme trois produits distincts, sans que rien ne dise ce qui les relie ni comment choisir. On apprend la syntaxe de chacun, et on reste bloqué sur la seule question qui compte au moment d'écrire : lequel, ici, maintenant.

Il y a une réponse, et elle est simple. Turbo ne fait qu'une chose : il intercepte une navigation, récupère du HTML, et remplace une portion du document. Drive, Frames et Streams ne sont pas trois mécanismes, ce sont trois **portées** de ce remplacement, qui se distinguent par une seule variable : qui désigne la cible.

Ce qui suit est ce que j'aurais voulu lire il y a trois ans. C'est long, c'est fait pour être relu par morceaux, et ça se termine par un index qui part du symptôme observable. Les versions de référence sont **Turbo 8.0.23** et **turbo-rails 2.0.23**, toutes deux publiées le 29 janvier 2026, sur Rails 8.

## Le modèle en une page

<figure>
  <img src="/images/posts/turbo/01-trois-portees.svg" alt="Trois panneaux comparant Turbo Drive qui remplace la page entière, Turbo Frames qui remplace un fragment désigné par le client, et Turbo Streams qui applique des ordres à des cibles désignées par le serveur." />
  <figcaption>La même mécanique, trois portées. La seule variable qui change est celle qui désigne la cible.</figcaption>
</figure>

Avec **Drive**, la cible est implicite : c'est le `<body>`. Le client décide de naviguer, le serveur répond une page, Turbo échange le corps et fusionne l'en-tête.

Avec **Frames**, la cible est décidée par le client avant même que la requête parte. Le frame qui émet met son identifiant dans un en-tête `Turbo-Frame`, et Turbo n'acceptera de la réponse que l'élément portant le même identifiant. Le serveur ne choisit rien : il subit une contrainte.

Avec **Streams**, la cible est écrite dans la réponse. Le serveur dit `remplace l'élément qui a cet id`, `ajoute ceci à la fin de celui-là`, `supprime cet autre`. Il peut viser plusieurs endroits, non contigus, et il peut le faire sans que personne n'ait rien demandé, via un WebSocket.

Tout le reste découle de ça. Un frame qui ne se met pas à jour, c'est presque toujours un identifiant qui ne correspond pas. Un stream qui n'a aucun effet, c'est presque toujours une cible absente du DOM. Le morphing, qu'on verra plus loin, ne change pas ce modèle : il change seulement *comment* le remplacement est appliqué, pas *qui* le décide.

## Turbo Drive

Drive est actif dès que vous chargez `@hotwired/turbo`. Il n'y a rien à écrire pour en profiter et, contrairement à ce que suggère l'ordre des chapitres de la documentation, c'est là que doit s'arrêter la majorité des besoins.

### Ce qui est intercepté, et ce qui ne l'est pas

Drive intercepte les clics sur les `<a href>` de même origine et les soumissions de formulaires. Il **n'intercepte pas** : les liens avec un attribut `target` autre que `_self`, les liens `download`, les URL cross-origin, et les URL dont l'extension figure dans `Turbo.config.drive.unvisitableExtensions` (une cinquantaine d'extensions, dont `.pdf`, `.zip`, `.csv`, `.jpg`). Cette dernière liste est configurable et n'est documentée nulle part sur le site officiel.

Vous coupez ponctuellement avec `data-turbo="false"` sur l'élément ou n'importe lequel de ses ancêtres. Un `data-turbo="true"` imbriqué plus bas réactive.

### Le cycle de vie

<figure>
  <img src="/images/posts/turbo/02-cycle-drive.svg" alt="Séquence verticale des événements d'une visite Drive, de turbo:click à turbo:load, avec l'aperçu depuis le cache et l'étape de remplacement du body." />
  <figcaption>Les cases en pointillés ne sont pas des événements : ce sont les étapes internes que les événements encadrent.</figcaption>
</figure>

Deux points méritent qu'on s'y arrête, parce qu'ils expliquent une bonne moitié des bugs de cycle de vie.

**L'instantané est pris de la page qu'on quitte, pas de celle qu'on charge.** `turbo:before-cache` se déclenche sur le document vivant, juste avant que Turbo n'en fasse un `cloneNode(true)`. C'est donc votre dernière chance de défaire ce que votre JavaScript a ajouté au DOM. Et il y a une asymétrie cruelle : le clone **perd les écouteurs d'événements** mais **garde le DOM injecté**. C'est exactement la recette de la bibliothèque initialisée deux fois au retour arrière, qu'on verra plus loin.

Détail utile : le clone remet aussi la sélection des `<select>` (que `cloneNode` perd), vide la valeur de tous les `input[type=password]`, et supprime les `<noscript>`.

**Un aperçu depuis le cache fait tirer `turbo:render` deux fois.** Si l'URL visée est dans le cache, Turbo affiche l'instantané immédiatement pendant que la requête réseau tourne. Pendant cet aperçu, `<html>` porte l'attribut `data-turbo-preview`. C'est le moyen de tester dans un contrôleur Stimulus :

```js
connect() {
  if (document.documentElement.hasAttribute("data-turbo-preview")) return
  this.initialiserLeWidgetCoûteux()
}
```

### Le cache, en chiffres

Le cache Drive est un LRU de **dix instantanés**, en mémoire, dans l'onglet. Pas de `localStorage`, pas d'IndexedDB, pas de Cache API. Il meurt à la fermeture de l'onglet, au rechargement, et il est intégralement vidé à **chaque** soumission de formulaire non sûre. Il ne contient que du HTML : aucune feuille de style, aucune image, aucun script.

Retenez la formulation : **le cache de Turbo est une optimisation de latence perçue, ce n'est pas une couche de persistance.** Il ne vous donne strictement rien hors ligne.

Trois leviers :

| Ce que vous voulez | Comment |
|---|---|
| Ne jamais mettre cette page en cache | `<meta name="turbo-cache-control" content="no-cache">` |
| La mettre en cache mais ne jamais l'afficher en aperçu | `<meta name="turbo-cache-control" content="no-preview">` |
| Retirer un élément avant la mise en cache | `data-turbo-temporary` sur l'élément |

`data-turbo-cache="false"` a été **supprimé en 8.0.21**, en janvier 2026, après trois ans de dépréciation. `Turbo.clearCache()` aussi, remplacé par `Turbo.cache.clear()`. Beaucoup de billets et de réponses Stack Overflow qui circulent encore les mentionnent.

### Turbo.config

Depuis 8.0.6, la configuration passe par un objet unique. Les anciennes fonctions de réglage (`setProgressBarDelay`, `setConfirmMethod`, `setFormMode`) existent toujours mais émettent un avertissement en console.

```js
Turbo.config.drive.progressBarDelay = 500          // ms avant la barre de progression
Turbo.config.drive.enabled          = true
Turbo.config.forms.mode             = "on"         // on | off | optin
Turbo.config.forms.submitter        = "disabled"   // disabled | aria-disabled
Turbo.config.forms.confirm          = async (message) => { /* Promise<boolean> */ }
```

`forms.submitter` mérite un mot, parce que c'est un cas où le défaut de Turbo est mauvais pour l'accessibilité. Par défaut, Turbo pose l'attribut `disabled` sur le bouton pendant la soumission. Un bouton `disabled` reste exposé dans l'arbre d'accessibilité, mais il perd sa focusabilité et sa place dans l'ordre de tabulation : le focus retombe sur `<body>`, et une personne qui navigue au clavier ou au lecteur d'écran perd sa position à chaque envoi de formulaire. Avec `"aria-disabled"`, Turbo pose `aria-disabled="true"` et annule les `click` sur le bouton : la protection contre le double envoi est identique, mais le contrôle reste focusable.

```js
Turbo.config.forms.submitter = "aria-disabled"
```

Une ligne, dans `application.js`. Il n'y a pas de raison de ne pas l'écrire.

### Préchargement

Depuis Turbo 8, le préchargement au survol est **actif par défaut**. Turbo attend 100 ms après le `mouseenter`, envoie un GET avec l'en-tête `X-Sec-Purpose: prefetch`, et garde la réponse dans un cache d'une seule entrée pendant 10 secondes.

Cela veut dire que passer la souris au-dessus d'un lien déclenche une requête chez vous. Si vos actions GET ne sont pas idempotentes, ou si votre serveur n'aime pas le trafic gratuit, coupez :

```erb
<meta name="turbo-prefetch" content="false">     <%%# global %>
<a href="/x" data-turbo-prefetch="false">…</a>   <%%# ponctuel %>
```

À ne pas confondre avec `data-turbo-preload`, qui est un autre mécanisme, un autre cache, et un chargement au `DOMContentLoaded` plutôt qu'au survol.

## Turbo Frames

### Le contrat tient dans un identifiant

<figure>
  <img src="/images/posts/turbo/03-contrat-frame.svg" alt="Un frame envoie l'en-tête Turbo-Frame, le serveur répond une page complète, Turbo n'extrait que le turbo-frame de même id, et le reste est jeté. En dessous, le déroulé de frame-missing." />
  <figcaption>Turbo fait une seule chose avec la réponse : il cherche un <code>&lt;turbo-frame&gt;</code> de même id. Tout le reste est jeté.</figcaption>
</figure>

La règle de correspondance, littéralement, est `container.querySelector("turbo-frame#" + CSS.escape(id))`. Pas de correspondance approchée, pas de sélecteur configurable. C'est pour ça que le patron le plus robuste consiste à laisser le serveur reprendre l'identifiant qu'on lui a envoyé, via la méthode d'aide `turbo_frame_request_id` de turbo-rails :

```erb
<%%= turbo_frame_tag turbo_frame_request_id || "invoice_detail" do %>
  …
<%% end %>
```

Le repli n'est pas décoratif : sur une visite pleine page, l'en-tête est absent et `turbo_frame_tag nil` produit `id=""`, ce qui casse silencieusement toute navigation ultérieure vers ce frame.

Il existe une seconde chance, peu utilisée : l'attribut `recurse`. Si aucun frame de même id n'est trouvé mais qu'un `<turbo-frame src recurse~="mon-id">` est présent, Turbo attend son chargement puis va chercher dedans.

Et si rien ne correspond, le déroulé est le suivant :

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`.

Le cas le plus fréquent est l'expiration de session : la requête part du frame, le serveur redirige vers `/login`, et la page de login ne contient évidemment pas votre frame. La bonne réponse n'est pas de gérer l'événement, c'est de marquer la page de login :

```erb
<%%= turbo_page_requires_reload %>
```

qui pose `<meta name="turbo-visit-control" content="reload">`. Turbo saute alors complètement l'extraction du frame et fait une visite pleine page. C'est l'échappatoire prévue, et elle est bien meilleure qu'un écouteur `turbo:frame-missing` global.

Un piège au passage, et il vaut aussi pour `turbo_refreshes_with` qu'on verra plus loin : ce helper appelle `provide :head`. **Il n'écrit rien à l'endroit de l'appel**, malgré le `<%%= %>`. Sans `<%%= yield :head %>` dans votre layout, la balise meta ne sort jamais et rien ne se passe. La variante `turbo_page_requires_reload_tag` rend la balise sur place si vous préférez la placer vous-même.

### Le layout, et le piège du layout statique

turbo-rails installe ceci dans `ActionController::Base` :

```ruby
layout -> { "turbo_rails/frame" if turbo_frame_request? }
etag   { :frame if turbo_frame_request? }
```

Le layout `turbo_rails/frame` est minimal (juste `csrf_meta_tags` et `yield :head`), pas absent, pour que `content_for :head` et le CSRF continuent de fonctionner.

Le piège : si vous écrivez `layout "admin"` dans un contrôleur, vous écrasez cette lambda. Les requêtes de frame rendront alors le layout complet. Ça **fonctionne quand même**, parce que Turbo extrait le frame de toute façon, mais vous payez le layout entier à chaque requête de frame, et tout le JavaScript injecté dans le `<head>` est réévalué. Il faut convertir la déclaration en méthode :

```ruby
layout :choisir_layout

private

def choisir_layout
  turbo_frame_request? ? "turbo_rails/frame" : "admin"
end
```

Aucune erreur, aucun avertissement. Juste une facture silencieuse.

### Les attributs qui comptent

| Attribut | Effet |
|---|---|
| `src` | Charge cette URL dans le frame. Passé par `url_for`, donc un modèle fonctionne. |
| `loading="lazy"` | Ne charge qu'à l'entrée dans le viewport, via un `IntersectionObserver`. |
| `target` | Cible par défaut des liens et formulaires **descendants**. |
| `data-turbo-frame` | Sur un lien, un formulaire ou un **bouton de soumission**. Écrase `target`. Le bouton gagne sur le formulaire. |
| `_top` | Sort du frame : Drive traite la navigation comme une visite pleine page. |
| `_parent` | Vise le frame ancêtre le plus proche. Ajouté en 8.0.21. |
| `busy` | Posé par Turbo pendant le chargement, avec `aria-busy="true"`. Utile en CSS. |
| `complete` | Posé après un rendu réussi. Attention : la propriété JS `frame.complete` ne lit pas cet attribut. |
| `disabled` | Annule la requête en cours et ignore toute navigation. |
| `autoscroll` | Fait défiler jusqu'au frame après rendu. `data-autoscroll-block` vaut `end` par défaut, pas `start`. |
| `refresh="morph"` | Avec `src`, le frame est rechargé plutôt que morphé pendant un morph de page. |

Deux comportements qui surprennent souvent :

**Un frame rend les 4xx et les 5xx exactement comme les 2xx.** Le code de statut n'est pas consulté du tout. Deux choses seulement peuvent détourner la réponse : un `Content-Type` non HTML, auquel cas `loadResponse` ne fait rien du tout, sans événement ni erreur, et le `turbo-visit-control: reload` vu plus haut, qui déclenche une visite pleine page avec un avertissement en console.

**Un formulaire dans un frame n'a pas besoin de rediriger.** La contrainte « les réponses de formulaire doivent rediriger » ne s'applique qu'aux soumissions pleine page. Un `200 OK` avec du HTML est parfaitement valide dans un frame.

## Turbo Streams

Un `<turbo-stream>` est une enveloppe. Elle porte une `action`, une cible, et un `<template>` :

```html
<turbo-stream action="replace" target="invoice_42">
  <template><div id="invoice_42">…</div></template>
</turbo-stream>
```

Il y a **deux mondes** derrière ce format, et les confondre est la première source de confusion.

Le premier monde est une **réponse HTTP** : l'utilisateur fait une action, le serveur répond du `text/vnd.turbo-stream.html`, l'onglet applique. C'est synchrone, ça vit dans le contexte de la requête, `current_user` existe.

Le second est un **broadcast** : un modèle publie sur un canal Action Cable, tous les onglets abonnés reçoivent. C'est asynchrone, ça tourne dans un job, il n'y a **ni requête, ni session, ni `current_user`**. On y revient plus bas, parce que c'est là que se cachent les vrais problèmes.

### Huit actions, et `morph` n'en est pas une

C'est l'erreur la plus répandue de tout l'écosystème, y compris dans des billets très lus.

| Action | Ce qu'elle fait | Cible requise |
|---|---|---|
| `append` | Ajoute à la fin du contenu de la cible | oui |
| `prepend` | Ajoute au début | oui |
| `before` | Insère avant la cible | oui |
| `after` | Insère après la cible | oui |
| `replace` | Remplace la cible elle-même | oui |
| `update` | Remplace le contenu de la cible | oui |
| `remove` | Supprime la cible. Pas de `<template>` | oui |
| `refresh` | Déclenche un rechargement de page. Pas de `<template>` | **non** |

C'est tout. Il n'y a pas d'action `morph`. Le morphing est un **attribut** `method="morph"`, et parmi les actions ciblées, **seuls `replace` et `update` le lisent**. Écrire `turbo_stream.append("x", method: :morph)` produit bien l'attribut dans le HTML, et le gestionnaire JavaScript de `append` l'ignore purement et simplement. `refresh` lit aussi `method`, mais pour une autre raison : y choisir le mode de rendu du rafraîchissement de page, broadcast par broadcast.

Deux comportements peu documentés :

`append` et `prepend` **dédoublonnent** : si un enfant direct de la cible porte le même `id` qu'un élément entrant de premier niveau, l'ancien est retiré. `append` se comporte donc comme un upsert, ce qui est très pratique et jamais dit. Depuis 8.0.21, `before` et `after` font la même chose sur les frères de la cible.

`refresh` est déduplicable via un attribut `request-id`, sujet de la section sur les broadcasts.

### `target` et `targets` ne sont pas la même chose

```ruby
turbo_stream.replace     "invoice_42"        # target  => getElementById
turbo_stream.replace_all ".invoice-row"      # targets => querySelectorAll
```

`target` prend un **identifiant DOM nu**, pas un sélecteur, et résout au plus un élément. `targets` prend un **sélecteur CSS** et applique l'action à toutes les correspondances. Si les deux sont présents, `target` gagne.

Côté Ruby, le helper vous aide : passer un enregistrement à `target:` donne `dom_id(record)`, et à `targets:` donne `"#" + dom_id(record)`. Vous n'avez pas à y penser, sauf le jour où vous construisez le sélecteur à la main.

### Le silence quand la cible n'existe pas

C'est le comportement le plus coûteux en temps de débogage de tout Turbo.

Si `document.getElementById(target)` retourne `null`, le getter retourne un tableau vide, et chaque action itère sur ce tableau vide. **Aucun avertissement, à aucun niveau de log.** Le stream arrive, il est visible dans l'onglet Réseau, il est visible dans les logs Rails, et il ne se passe rien.

Les causes classiques : un `dom_id` mal orthographié ou mis au pluriel, une cible qui vit à l'intérieur d'un `<template>` ou d'une `<iframe>` (explicitement non supportés), ou une cible qui est dans un frame `loading="lazy"` pas encore chargé.

Il existe une seconde variante silencieuse, plus fourbe : sur une requête **GET**, Turbo n'envoie l'en-tête `Accept: text/vnd.turbo-stream.html` que si le lien ou le formulaire porte `data-turbo-stream`. Sans cet attribut, votre `respond_to` ne verra jamais le format `turbo_stream` et tombera dans la branche HTML.

### Ajouter vos propres actions

Le mécanisme est plus simple qu'il n'y paraît et c'est un bon investissement dès que vous vous surprenez à empiler des streams pour exprimer une seule intention.

Côté Ruby, un hook de chargement :

```ruby
# config/initializers/turbo.rb
ActiveSupport.on_load :turbo_streams_tag_builder do
  def surligner(target)      = action     :surligner, target
  def surligner_tous(targets) = action_all :surligner, targets
end
```

Côté JavaScript, une entrée dans `Turbo.StreamActions`, où `this` est l'élément `<turbo-stream>` :

```js
Turbo.StreamActions.surligner = function () {
  this.targetElements.forEach((el) => {
    el.animate([{ backgroundColor: "#FFD83F" }, { backgroundColor: "transparent" }],
               { duration: 1200 })
  })
}
```

Et depuis un modèle, sans passer par le builder :

```ruby
after_update_commit -> {
  broadcast_action_to "plannings", action: :surligner, target: "gantt", html: ""
}
```

Si l'action n'est pas enregistrée côté JavaScript, l'élément lève `unknown action`. C'est un des rares endroits où Turbo est bruyant.

## Le statut HTTP, et pourquoi 422

<figure>
  <img src="/images/posts/turbo/04-reponse.svg" alt="Arbre de décision : content-type turbo-stream, puis requête de frame, puis statut HTTP, avec les quatre issues 200, 4xx, 5xx et 3xx." />
  <figcaption>Trois questions, dans cet ordre. Le statut HTTP n'est consulté qu'en dernier, et seulement pour une navigation pleine page.</figcaption>
</figure>

Le symptôme numéro un de tout Hotwire tient dans une ligne de code de Turbo :

```js
responseSucceededWithoutRedirect(response) {
  return response.statusCode == 200 && !response.redirected
}
```

Si vous répondez `200` avec du HTML à un POST sans redirection, Turbo affiche `console.error("Form responses must redirect to another location")` et **ne rend rien**. La page paraît figée. Le formulaire est parti, la barre de progression est allée au bout, et les messages d'erreur ne sont jamais apparus.

La raison est donnée dans le manuel, et elle est bonne : les navigateurs ont un comportement natif pour le rechargement d'une page issue d'un POST, cette boîte de dialogue « voulez-vous renvoyer le formulaire ? », que Turbo ne peut pas reproduire. Plutôt que de mentir sur l'URL, il refuse.

D'où la convention Rails, que le générateur de scaffold applique déjà :

```ruby
render :new, status: :unprocessable_entity  # 422 : la réponse est rendue, l'URL ne bouge pas
redirect_to @invoice, status: :see_other    # 303 : après update et destroy
```

Le `303` n'est pas une lubie de Rails, c'est une conséquence de la spécification Fetch. Turbo passe `redirect: "follow"` et laisse le navigateur suivre. Or :

- **303** force toujours le passage en `GET`.
- **301** et **302** ne forcent le passage en GET **que pour POST**. Un `DELETE` redirigé en 302 est **rejoué en `DELETE`** sur l'URL cible.
- **307** et **308** préservent toujours la méthode.

Un `redirect_to invoices_path` après un `destroy` sans `status: :see_other` envoie donc un `DELETE /invoices` à votre serveur. Au mieux une erreur de routage, au pire quelque chose que vous n'aviez pas prévu.

Une nuance qui simplifie la vie : **une réponse turbo-stream court-circuite tout ça**. Turbo intercepte sur le `Content-Type` avant même de regarder le statut. `render turbo_stream: …, status: :unprocessable_entity` fonctionne parfaitement, et le 422 ne sert plus qu'à vos tests et aux clients non-Turbo.

Petit détail amusant : le test porte sur `statusCode == 200` exactement. Un `201 Created` passe à travers le garde-fou et poursuit vers une visite.

## Le morphing

Turbo 8 a introduit les *page refreshes* avec morphing. L'idée : plutôt que de remplacer le `<body>`, comparer l'ancien arbre et le nouveau, et ne modifier que ce qui diffère. On y gagne le scroll, le focus, la sélection de texte, l'état des transitions CSS.

On l'active en deux lignes, dans le layout :

```erb
<%%= turbo_refreshes_with method: :morph, scroll: :preserve %>
```

Et voici le premier piège, responsable d'une bonne part des « le morphing ne marche pas chez moi » : `turbo_refreshes_with` appelle `provide :head`. **Il n'écrit rien à l'endroit où vous l'appelez.** Sans `<%%= yield :head %>` dans votre layout, les balises meta ne sortent jamais, et Turbo continue de faire des remplacements classiques, sans rien signaler. Les valeurs acceptées sont `:replace` ou `:morph` pour `method:`, `:reset` ou `:preserve` pour `scroll:`, tout le reste lève une `ArgumentError`.

### Ce qui déclenche réellement un morph

Un morph n'a lieu que pour un **page refresh**, et la condition exacte tient en deux clauses : **même `pathname`** (la query string et le fragment ne comptent pas) **et `action === "replace"`**.

En pratique, pour un formulaire, ça revient à « même URL », parce que Turbo ne choisit `replace` que si l'URL d'arrivée est strictement identique à l'URL de départ. Un `POST /invoices/42` qui redirige vers `/invoices/42` morphe. Un `POST /invoices/42/edit` qui redirige vers `/invoices/42` ne morphe pas : deux chemins différents, rendu classique.

Mais la clause `action` compte pour le reste. Un lien `data-turbo-action="replace"` de `/invoices?page=2` vers `/invoices?page=3` morphe aussi, puisque le `pathname` est le même.

Les deux déclencheurs les plus courants : un formulaire qui redirige sur la même URL, et un broadcast `<turbo-stream action="refresh">`.

### Ce que le morphing enlève

<figure>
  <img src="/images/posts/turbo/05-morph.svg" alt="Comparaison des deux pipelines de rendu : classique avec before-cache, remplacement du body et réexécution des scripts, contre morph où before-cache ne se produit plus et où rien n'est réexécuté." />
  <figcaption>Même début, même fin. C'est le milieu qui change, et le milieu est là où vit votre code.</figcaption>
</figure>

Trois corrections à du folklore très répandu :

**`turbo:load` se déclenche bien après un morph.** Un refresh est une vraie visite. Ce qui ne rejoue pas, ce sont les `<script>` inline.

**`turbo:before-cache` ne se déclenche plus.** Le refresh passe `shouldCacheSnapshot: false`. Or `turbo:before-cache` est *le* point de démontage global recommandé partout depuis Turbolinks. Activer le morphing désactive donc silencieusement le code de nettoyage de la moitié des applications Rails existantes.

**Les scripts ne sont pas « jamais réexécutés », ils sont réexécutés s'ils sont nouveaux.** idiomorph apparie l'ancien `<script>` au nouveau par nom de balise, puis synchronise le contenu textuel par `oldNode.nodeValue = newNode.nodeValue`. Assigner `nodeValue` à un script déjà exécuté ne le réexécute jamais. En revanche, un script réellement nouveau, sans partenaire dans l'ancien arbre, est inséré et **s'exécute**.

### idiomorph décide par les identifiants

Un `id` n'est considéré comme stable que si les trois conditions suivantes sont réunies : il existe dans les deux arbres, **le nom de balise est identique**, et il **n'est dupliqué dans aucun des deux**.

Cette troisième condition mérite d'être encadrée. Un seul `id` dupliqué quelque part dans le document fait sortir cet `id` de l'ensemble des identifiants persistants, **dans les deux copies**. L'appariement retombe alors sur la position. Un doublon accidentel dans un partiel dégrade donc le morphing d'éléments qui n'ont rien à voir.

Sans identifiants stables, tout est apparié par position. Ajouter une ligne en tête de liste réécrit le texte de **toutes** les lignes plutôt que d'insérer un nœud. Conséquences : les transitions CSS repartent partout, et tout état client porté par une ligne (un menu ouvert, une case cochée, une vidéo en lecture) se décale d'un cran.

La règle est courte : **donnez un `id` stable et unique à chaque élément de liste**, ne le réutilisez jamais, ne changez jamais la balise associée. `dom_id(record)` fait exactement ça.

### Le garde-manger, et pourquoi ça casse en Safari

Voici le mécanisme qui explique les rapports de bug « ça marche chez moi mais pas chez lui », et qui n'est documenté nulle part.

Quand un nœud à identifiant persistant doit changer de place, idiomorph ne le clone pas. Il le déplace dans un `<div>` caché inséré **après `</body>`**, le garde-manger, puis le remet en position plus tard. Le déplacement utilise `parentNode.moveBefore()` **si le navigateur le fournit**, sinon `insertBefore`.

Ce n'est pas un détail d'implémentation, c'est une bifurcation de comportement :

- Avec `moveBefore` (Chromium 133 et plus, Firefox 144 et plus), le nœud **reste connecté**. Pas de `disconnect`/`connect` Stimulus, les `<iframe>` ne rechargent pas, une `<video>` continue de jouer.
- Avec `insertBefore`, le nœud est **déconnecté puis reconnecté**. `disconnect()` puis `connect()` sont appelés, les `<iframe>` rechargent, la lecture vidéo repart de zéro, les transitions CSS redémarrent, et les `connectedCallback` des éléments personnalisés rejouent.

À la date où j'écris, le camp `insertBefore` s'est réduit à Safari et WebKit iOS, plus les versions antérieures à Chrome 133 et Firefox 144. Donc oui, un bug de morphing peut être parfaitement reproductible en Safari et introuvable en Chrome. Ce n'est pas votre code.

### La valeur des champs n'est pas protégée

idiomorph ne synchronise pas seulement les attributs, il écrit aussi les **propriétés DOM vivantes** des contrôles de formulaire. Le code est explicite :

```js
if (!newElement.hasAttribute("value")) {
  if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
    oldElement.value = ""           // ce que l'utilisateur avait tapé
    oldElement.removeAttribute("value")
  }
}
```

Or un `<input type="text">` rendu par le serveur n'a normalement **pas** d'attribut `value`. Un refresh qui arrive pendant que quelqu'un tape efface donc ce qu'il tape. Même traitement pour `checked`, `disabled`, `<option selected>` et le contenu des `<textarea>`.

idiomorph possède une option `ignoreActiveValue` qui exclut `document.activeElement` de cette synchronisation. **Turbo ne l'active pas.** Le contournement est dans les fixtures de test de Turbo, ce qui en fait la réponse la plus officielle qui existe :

```js
addEventListener("focusin", ({ target }) => {
  if (target instanceof HTMLInputElement && !target.hasAttribute("data-turbo-permanent")) {
    target.toggleAttribute("data-turbo-permanent", true)
    target.addEventListener("focusout", () => {
      target.toggleAttribute("data-turbo-permanent", false)
    }, { once: true })
  }
})
```

Sur le focus, plus précisément : idiomorph restaure le focus, mais uniquement pour un `<input>` ou un `<textarea>` **qui porte un `id`**. Un `<select>` focalisé, un `contenteditable` focalisé ou un champ sans `id` perdent le focus, et il n'y a pas d'option pour changer ça.

Et l'`autofocus` n'est pas honoré du tout sous morph : `MorphingPageRenderer` déclare `shouldAutofocus` à `false`.

### La boîte à outils de protection

Du plus fin au plus brutal :

```js
// 1. Protéger un attribut précis
document.addEventListener("turbo:before-morph-attribute", (event) => {
  const { attributeName } = event.detail            // + mutationType: "update" | "remove"
  if (attributeName === "open") event.preventDefault()
})

// 2. Protéger un sous-arbre entier
document.addEventListener("turbo:before-morph-element", (event) => {
  if (event.target.matches(".widget-tiers")) event.preventDefault()
})

// 3. Réinitialiser après coup
document.addEventListener("turbo:morph-element", ({ target }) => { /* … */ })
```

```erb
<%%# 4. Le gel complet %>
<div id="carte" data-turbo-permanent>…</div>
```

Deux précisions que la documentation ne donne pas.

**`data-turbo-permanent` n'a pas la même sémantique selon le mode de rendu.** En rendu classique, le sélecteur est `[id][data-turbo-permanent]` : **l'`id` est obligatoire**, et le nœud vivant est transplanté dans le nouveau corps. Sous morph, seul l'attribut est testé, l'`id` n'est pas requis, et le nœud est simplement ignoré. L'`id` redevient nécessaire pour les nœuds **ajoutés**, où il sert au dédoublonnage.

**Sous morph, `data-turbo-permanent` gèle le sous-arbre complètement.** Les mises à jour serveur légitimes à l'intérieur n'arriveront jamais. C'est l'outil de dernier recours, pas le réflexe.

Enfin, `turbo:before-morph-element` est aussi émis pour les nœuds sur le point d'être **supprimés**, et dans ce cas `detail.newElement` vaut `undefined`. Un écouteur qui écrit `event.detail.newElement.matches(…)` lèvera une `TypeError` un jour ou l'autre.

## Les broadcasts

C'est là que le modèle mental doit être le plus solide, parce que le code s'écrit en une ligne et que les problèmes arrivent en production.

### Quatre macros, et une asymétrie

```ruby
class Card < ApplicationRecord
  broadcasts_refreshes_to :board   # 1 seul after_commit, tout part vers board
end
```

| Macro | Création | Mise à jour | Suppression |
|---|---|---|---|
| `broadcasts_to :board` | `append` vers `board` | `replace` vers `board` | `remove` vers `board` |
| `broadcasts` | `append` vers `"cards"` | `replace` vers **le flux GID de l'enregistrement** | `remove` vers **le flux GID** |
| `broadcasts_refreshes` | `refresh` vers `"cards"` | `refresh` vers **le flux GID** | `refresh` vers **le flux GID** |
| `broadcasts_refreshes_to :board` | `refresh` vers `board` | `refresh` vers `board` | `refresh` vers `board` |

Lisez la deuxième et la troisième ligne deux fois. `broadcasts` et `broadcasts_refreshes` envoient les **créations** vers le flux de la collection, mais les **mises à jour et les suppressions vers le flux propre de l'enregistrement**. Une page qui ne fait que `turbo_stream_from "cards"` verra apparaître les nouvelles cartes et ne verra **jamais** les modifications ni les suppressions.

C'est intentionnel : le patron visé est `turbo_stream_from Card` sur l'index et `turbo_stream_from @card` sur la page de détail. Mais ce n'est écrit nulle part de façon lisible. Si vous voulez que les trois événements aillent au même endroit, la macro à utiliser est `broadcasts_refreshes_to`, qui pose un unique `after_commit`.

Autre asymétrie utile à connaître : pour les trois premières macros, les suppressions sont **synchrones** alors que les créations et mises à jour passent par un job. C'est logique (une suppression n'a rien à rendre), mais ça veut dire que la suppression n'est **pas anti-rebondie**. `broadcasts_refreshes_to` échappe à ça justement parce qu'elle n'installe qu'un seul `after_commit` : tout y est asynchrone et anti-rebondi, y compris le `destroy`.

### Le chemin complet d'un refresh

<figure>
  <img src="/images/posts/turbo/06-broadcast.svg" alt="Diagramme de séquence entre l'onglet qui écrit, le serveur et un onglet spectateur, montrant l'en-tête X-Turbo-Request-Id, l'anti-rebond de 0,5 seconde, et la suppression du refresh dans l'onglet d'origine." />
  <figcaption>L'onglet qui écrit reçoit son propre broadcast. Tout le mécanisme d'identifiant sert à lui permettre de l'ignorer.</figcaption>
</figure>

Chaque `fetch` de Turbo génère un UUID, l'ajoute à un ensemble borné à 20 entrées, et l'envoie en `X-Turbo-Request-Id`. Côté Rails, un `around_action` le recopie dans `Turbo.current_request_id`. Le broadcast le réémet dans l'attribut `request-id`. À la réception, `Session#refresh` ignore le refresh si l'identifiant est dans son ensemble local.

La raison est bonne : l'onglet qui a écrit a **déjà** affiché le résultat de sa requête. Rejouer le refresh coûterait un aller-retour inutile, la position de scroll et le focus.

Quatre manières de perdre cette protection, toutes vérifiables dans le code :

1. **`broadcast_refresh_to`, la variante synchrone, ne transmet aucun `request_id`.** Seule la variante `_later_` le fait. C'est aussi la variante qu'installe la macro pour le `destroy`.
2. **Depuis un job de fond**, `Turbo.current_request_id` est nul : c'est un `thread_mattr_accessor` posé par un `around_action`. Tous les onglets rafraîchissent, y compris celui à l'origine. C'est en général ce que vous voulez.
3. **L'ensemble est borné à 20.** Plus de 20 requêtes Turbo entre l'envoi et l'arrivée du broadcast et l'identifiant est évincé. Avec le préchargement au survol actif par défaut, ce n'est plus si théorique.
4. **`turbo_stream.refresh` rendu en réponse directe** à une requête porte par défaut l'identifiant de cette requête, donc l'onglet demandeur l'ignore. Il faut écrire `turbo_stream.refresh(request_id: nil)`.

### L'anti-rebond, et ce qu'il coûte

`broadcast_refresh_later_to` passe par un `Turbo::ThreadDebouncer` mémorisé dans `Thread.current`, clé `(nom de flux, request_id)`, qui programme un `Concurrent::ScheduledTask` **0,5 seconde** dans le futur. Chaque nouvel appel annule le précédent. Mille enregistrements modifiés dans une requête donnent donc **un** broadcast.

Côté client, `Session#refresh` est en plus anti-rebondi à 150 ms.

Deux conséquences moins agréables :

**Dans un processus court, le broadcast n'est jamais envoyé.** Un `rails runner`, une tâche rake, un conteneur qui sort après son travail : le processus se termine avant que la tâche programmée ne se déclenche. Aucune erreur. Le contournement documenté est un `sleep Turbo::Debouncer::DEFAULT_DELAY + 0.1`.

**Dans les tests, il n'y a pas d'anti-rebond du tout.** turbo-rails installe un `Turbo::ImmediateDebouncer` en environnement de test. Vos assertions comptent N broadcasts, la production en verra 1.

### `current_user` n'existe pas dans un broadcast

Tout broadcast asynchrone rend son partiel via :

```ruby
ApplicationController.render(formats: [format], **rendering)
```

C'est-à-dire un `ActionController::Renderer` avec un environnement Rack synthétique. Il n'y a ni session, ni cookies, ni clé Warden. Conséquences mécaniques :

- **`current_user` vaut `nil`.** Devise retourne `nil` plutôt que de lever : un partiel qui masque un bouton d'administration derrière `if current_user.admin?` rend donc la branche non-admin **pour tout le monde**, silencieusement, sans rien en console ni dans les logs.
- **Les attributs `Current.*` sont vides**, pour la même raison.
- **`ApplicationController` est codé en dur.** Pas de `prepend_view_path`, pas d'ivars posées par un `before_action`, pas de helpers d'un autre contrôleur.
- **Les helpers `_url` lèvent `Missing host to link to!`** sous Rails 7.1 et suivants, parce que les valeurs par défaut du renderer n'incluent plus de `http_host`. Les helpers `_path` fonctionnent.

Le patron qui passe à l'échelle est le seul qui accepte que le HTML diffusé soit **le même pour tous les destinataires** : diffuser un partiel neutre, et faire charger les parties personnalisées par un frame, que chaque navigateur ira chercher avec ses propres cookies.

```erb
<%%# le partiel diffusé, identique pour tous %>
<div id="<%%= dom_id(card) %>">
  <%%= card.title %>
  <%%= turbo_frame_tag "#{dom_id(card)}_actions", src: card_actions_path(card), loading: :lazy %>
</div>
```

Les alternatives : diffuser un flux par utilisateur, ce qui est correct mais en O(utilisateurs), ou passer explicitement tout ce dont vous avez besoin par `locals:` et traiter « ce partiel est diffusable » comme une propriété stricte du partiel.

C'est aussi, incidemment, le meilleur argument en faveur de `broadcasts_refreshes` : un refresh ne rend rien côté serveur. Chaque navigateur refait sa propre requête, avec sa propre session. Le problème disparaît par construction.

### Un flux signé n'est pas un flux autorisé

```erb
<%%= turbo_stream_from "quotes" %>
```

Cette ligne produit un `signed-stream-name` **identique pour tous les utilisateurs**. La signature empêche la falsification, pas la lecture : le nom est un `MessageVerifier`, donc du base64 signé, pas du chiffré. Deux `base64 -d` séparent le source de votre page de la chaîne `gid://app/Account/5`, et personne n'a besoin de votre clé pour les faire.

Et `Turbo::StreamsChannel#subscribed` **accepte l'abonnement sans aucune vérification supplémentaire**. Il vérifie que la signature est valide, point. Quiconque possède un nom signé valide peut s'abonner, y compris un ancien salarié dont vous avez révoqué l'accès, indéfiniment : ces noms n'expirent pas.

Il faut donc porter le périmètre dans le nom du flux :

```ruby
broadcasts_refreshes_to ->(quote) { [quote.company, :quotes] }
```

```erb
<%%= turbo_stream_from current_company, :quotes %>
```

Et si vous avez besoin d'une vraie autorisation, par exemple parce que l'appartenance peut être révoquée, il faut écrire votre propre canal et faire le contrôle avant `stream_from`.

### Deux pièges de plomberie

**En développement, l'adaptateur Action Cable par défaut est `async`, mono-processus.** Un broadcast déclenché depuis `bin/rails console` n'atteindra jamais un navigateur connecté à un serveur lancé séparément. Passez sur `redis` dans `config/cable.yml`, ou utilisez `<%%= console %>` pour déclencher dans le même processus.

**Les jobs de turbo-rails héritent de `ActiveJob::Base`, pas de votre `ApplicationJob`.** Votre politique de réessai, votre file et vos callbacks ne s'appliquent pas. Les trois jobs font `discard_on ActiveJob::DeserializationError` : un enregistrement supprimé entre la mise en file et l'exécution fait disparaître le broadcast, sans réessai et sans erreur.

## Stimulus et les bibliothèques tierces

### Le cycle de vie, précisément

Stimulus ne sait rien de Turbo. Il réagit à un `MutationObserver`, dans la micro-tâche qui suit chaque modification.

Sur une visite classique, l'ordre est : `turbo:before-cache`, puis l'échange de `<body>`, donc `disconnect()` des anciens contrôleurs et `connect()` des nouveaux, puis `turbo:render` et `turbo:load`. Notez que **`disconnect()` arrive après `turbo:before-cache`** : l'instantané est pris avant. C'est pour ça que `turbo:before-cache` était historiquement le bon endroit pour défaire ce qu'une bibliothèque avait injecté.

Sous morph, il n'y a **ni `turbo:before-cache`, ni `disconnect`/`connect`** pour les éléments modifiés en place. Les callbacks ne se déclenchent que si le nœud est réellement ajouté ou supprimé, s'il est reparenté via le garde-manger sur le chemin `insertBefore`, ou si la valeur de `data-controller` change.

Le patron de reconnexion recommandé vient, là encore, des fixtures de test de Turbo :

```js
addEventListener("turbo:morph-element", ({ target }) => {
  for (const { element, context } of application.controllers) {
    if (element === target) {
      context.disconnect()
      context.connect()
    }
  }
})
```

C'est cher sur une grande page, puisque ça reconnecte tous les contrôleurs de tous les éléments morphés, mais c'est la réponse la plus officielle qui existe.

### Ce qui casse, et pourquoi

La règle générale : **toute bibliothèque qui injecte du DOM que le serveur ne rend pas, ou qui écrit du `class` ou du `style` à l'exécution, est incompatible avec le morphing sans protection explicite.**

| Bibliothèque | Ce qui casse | La cause | La parade |
|---|---|---|---|
| Chart.js, Chartkick | Le graphique disparaît, un « Loading… » reste | Chartkick écoute `turbo:before-render` et détruit tous les graphiques, y compris quand `renderMethod` vaut `morph`. Le `<script>` qui le recréerait ne se réexécute pas | `Chartkick.config.autoDestroy = false`, puis redessiner sur `turbo:morph` |
| Alpine.js | Les éléments redeviennent invisibles, les `:class` sautent | Alpine retire `x-cloak` à l'init et écrit `class` et `style.display` à l'exécution. Le HTML serveur ne les connaît pas, le morph les rétablit | Annuler `turbo:before-morph-attribute` pour `x-cloak`, `class` et `style` sur les sous-arbres `[x-data]` |
| Tom Select, Select2, Choices | Le widget disparaît, la sélection revient en arrière | Le `<div>` injecté n'est pas dans le HTML serveur, donc supprimé. Le `<select>` d'origine survit, donc pas de reconnexion | `data-turbo-permanent` **avec un `id`**, ou détruire et réinitialiser sur `turbo:morph-element` |
| Leaflet, Mapbox GL | Carte morte, tuiles grises, ou `Map container is already initialized` | Les panneaux injectés sont supprimés, le conteneur survit | `map.remove()` dans `disconnect()`, `invalidateSize()` après reconnexion |
| `<dialog>` ouvert en `showModal()` | La page devient inutilisable | Le morph réécrit le contenu mais ne réinitialise pas le *top layer* du navigateur | Ticket ouvert et non résolu. Fermer le dialogue sur `turbo:before-render`, ou l'exclure du morph |
| `<details>` | Les panneaux s'ouvrent ou se ferment tout seuls chez tous les spectateurs | `open` est synchronisé comme un attribut ordinaire | Annuler `turbo:before-morph-attribute` pour `attributeName === "open"` |
| `<turbo-cable-stream-source>` | Des broadcasts sont perdus | S'il est reparenté sur le chemin `insertBefore`, il se désabonne puis se réabonne | Lui donner un `id` stable et le sortir des zones réordonnées |

**Trix et Action Text sont corrigés depuis mars 2025**, contrairement à ce qu'affirment encore beaucoup de billets. La technique employée mérite d'être connue, parce que c'est le meilleur patron général pour un élément personnalisé sous morph : `<trix-editor>` pose un attribut `connected` à l'initialisation et le déclare dans `observedAttributes`. Le HTML serveur ne le contient pas, donc **le morph le retire**, `attributeChangedCallback` se déclenche, et l'élément se réinitialise tout seul.

C'est un élément personnalisé auto-réparant. Si vous écrivez le vôtre, faites ça.

### Ce qui fuit si vous ne nettoyez pas

Turbo transforme votre application en processus long. Tout ce que vous créez dans `connect()` doit être démonté dans `disconnect()`, sans exception : les `setInterval`, les `addEventListener` sur `window` ou `document`, les `IntersectionObserver` et `ResizeObserver` (qui maintiennent en vie le nœud observé et bloquent la collecte de tout le sous-arbre détaché), les abonnements Action Cable, les instances Chart.js.

Le cas des contextes WebGL vaut d'être cité : les navigateurs les plafonnent autour de seize. Une carte Mapbox non détruite, seize navigations, et tous vos canvas deviennent blancs.

### Injecter un stream sans réponse HTTP

`Turbo.renderStreamMessage()` accepte une chaîne HTML contenant des `<turbo-stream>` et les applique comme s'ils étaient arrivés par le réseau. Les `<script>` à l'intérieur des `<template>` sont activés, les éléments permanents préservés, le focus restauré par `id`.

C'est ce qu'il faut pour : un transport autre qu'Action Cable (WebSocket brut, SSE, `postMessage` depuis un service worker), une mise à jour optimiste synthétisée côté client avant confirmation du serveur, ou un shell natif qui pousse du HTML dans la webview.

```js
Turbo.renderStreamMessage(
  `<turbo-stream action="append" target="messages"><template>…</template></turbo-stream>`
)
```

## Réseau et hors-ligne

### Quand la requête échoue, il ne se passe rien

Turbo émet `turbo:fetch-request-error`, qui remonte et traverse les *shadow roots*, avec `detail.request` et `detail.error`. Un écouteur posé sur `document` l'attrape de façon fiable.

Ce qui arrive ensuite dépend du contexte, et la différence est brutale.

**Pour un frame ou une soumission de formulaire, l'utilisateur ne voit rien.** Pas de bandeau, pas de toast. `console.error`, et le frame reste sur son ancien contenu.

**Pour une visite Drive, Turbo recharge complètement la page.** L'échec réseau est enregistré comme `SystemStatusCode.networkFailure`, l'adaptateur émet `turbo:reload` avec `reason: "request_failed"` et fait `window.location.href = …`. Hors ligne, cela veut dire : la page d'erreur réseau du navigateur, et votre application détruite. C'est le pire moment pour perdre l'état client.

Ce détail change la nature de l'écouteur ci-dessous. Le `preventDefault()` n'est pas cosmétique : il fait retourner `false` à la garde interne de Turbo, ce qui court-circuite la gestion d'erreur et donc **le rechargement**.

```js
document.addEventListener("turbo:fetch-request-error", (event) => {
  event.preventDefault()   // empêche aussi le rechargement complet sur une visite Drive
  afficherBandeauHorsLigne(event.detail.error)
})
window.addEventListener("offline", () => afficherBandeauHorsLigne())
window.addEventListener("online",  () => masquerBandeauHorsLigne())
```

Attention à un piège d'ordre : `turbo:before-fetch-response` ne se déclenche **que s'il y a une réponse**. Une détection de perte réseau construite dessus ne se déclenchera jamais quand le réseau est réellement coupé.

### Turbo et les service workers

Les navigations Turbo sont des appels `window.fetch()`. Turbo ne passe pas d'option `mode`, donc côté service worker la requête a `mode === "cors"` et, ce qui surprend davantage, **`destination === ""`**, la valeur par défaut de tout ce qui sort de `fetch()`.

Un service worker qui route sur `request.mode === 'navigate'` rate donc toutes les navigations Drive. Et le correctif qu'on lit partout, ajouter `|| request.destination === 'document'`, ne rattrape rien du tout : ce `destination` n'existe que pour les navigations initiées par le navigateur lui-même. Il faut discriminer sur ce que Turbo, lui, envoie vraiment :

```js
const estNavigationDocument = ({ request }) =>
  request.method === 'GET' && (
    request.mode === 'navigate' ||                                   // navigation navigateur
    (request.destination === '' &&                                   // fetch() de Turbo
     (request.headers.get('Accept') || '').includes('text/html'))
  )

registerRoute(estNavigationDocument, new NetworkFirst())
registerRoute(
  ({ request }) => ['style', 'script', 'image', 'font'].includes(request.destination),
  new CacheFirst()
)
```

La seconde route, elle, fonctionne telle quelle : ces requêtes sont bien initiées par le navigateur et portent un `destination` renseigné.

Deux façons de casser Turbo depuis un service worker :

**Servir une réponse au mauvais `Content-Type`.** Turbo enregistre `contentTypeMismatch` et abandonne la visite en silence.

**Servir un HTML dont les empreintes d'assets ne correspondent plus.** Turbo compare les éléments `data-turbo-track="reload"` entre instantanés. En cas de divergence, il déclenche un rechargement complet du navigateur, auquel le service worker répond de nouveau depuis le cache. Vous avez une boucle de rechargement.

Trois contraintes propres à Rails 8 :

- **Placez le service worker à une URL racine stable.** Un asset digéré par Propshaft ne peut pas contrôler une portée au-delà de son répertoire, et son URL change dès que son contenu change. Deux options : `public/service-worker.js`, ou la route que Rails 8 génère, qui est **commentée par défaut** dans `config/routes.rb` et qu'il faut donc décommenter : `get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker`. Notez le contrôleur `rails/pwa` et le chemin sans `.js`.
- **Les importmaps ne s'appliquent pas dans un service worker.** Il n'y a pas de `<script type="importmap">` là-bas. `importScripts()` ou un worker classique sans imports.
- **Les URL d'assets digérés changent avec leur contenu.** Une liste de préchargement écrite à la main périme au premier déploiement qui touche un fichier. Préférez la mise en cache à l'exécution, par `request.destination`.

Un patch officiel est en cours ([turbo#1427](https://github.com/hotwired/turbo/pull/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

<figure>
  <img src="/images/posts/turbo/07-websocket.svg" alt="Frise chronologique montrant des broadcasts perdus pendant une coupure de WebSocket, sans erreur ni log, et l'encadré sur l'attribut connected comme seul signal." />
  <figcaption>Le seul signal disponible est un attribut sur un élément. Il n'y a ni événement, ni numéro de séquence, ni rattrapage.</figcaption>
</figure>

C'est, à mon avis, le risque de production le moins documenté de tout Hotwire.

Action Cable est du publish/subscribe sans persistance, sans accusé de réception, sans numéro de séquence et sans historique. Un message publié pendant qu'un consommateur est déconnecté est délivré à ceux qui sont abonnés à cet instant, puis jeté. Il n'y a rien dans le protocole capable de le rejouer.

Action Cable se reconnecte tout seul, et l'onglet reçoit de nouveau les messages **suivants**. Tout ce qui est passé pendant la coupure est **définitivement perdu**. La page est alors silencieusement, indéfiniment périmée. Pas d'erreur, pas d'indication, rien.

Wifi qui bascule, ordinateur qui se réveille, tunnel, déploiement : ce n'est pas un cas limite, c'est le quotidien d'un utilisateur mobile.

Toute la surface d'observation tient dans un attribut : `<turbo-cable-stream-source>` pose et retire `connected`. Aucun événement n'est émis. D'où la parade, qui est du code applicatif :

```js
// contrôleur Stimulus posé sur <turbo-cable-stream-source>
connect() {
  this.observer = new MutationObserver(() => {
    const connecte = this.element.hasAttribute("connected")
    if (connecte && this.etaitDeconnecte) {
      Turbo.visit(location.href, { action: "replace" })
    }
    this.etaitDeconnecte = !connecte
  })
  this.observer.observe(this.element, { attributeFilter: ["connected"] })
}

disconnect() { this.observer.disconnect() }
```

Traitez aussi `visibilitychange` (le réveil de la machine) et `window.online`.

Avec `turbo_refreshes_with method: :morph, scroll: :preserve`, ce rattrapage coûte un aller-retour et préserve le scroll et le focus. **C'est le meilleur argument pratique en faveur du couple morphing plus broadcasts** : le rattrapage devient assez bon marché pour qu'on l'écrive.

Dernière chose à savoir dans le même registre : `Session#refresh` abandonne aussi un refresh qui arrive pendant qu'une navigation est déjà en cours (`!this.navigator.currentVisit`), sans réessai. C'est un second chemin, plus étroit, vers la même péremption silencieuse.

## L'arbre de décision

<figure>
  <img src="/images/posts/turbo/08-decision.svg" alt="Quatre questions en cascade : l'URL doit-elle changer, une seule zone avec une URL propre, plusieurs zones ou une mise à jour serveur, état purement client. Menant respectivement à Drive, Frame, Streams, Stimulus." />
  <figcaption>La première réponse positive donne l'outil. Descendre d'un cran coûte toujours plus cher que de s'arrêter.</figcaption>
</figure>

Le test qui tranche pour les frames est celui de l'URL : **s'il n'existe pas d'URL qui rende ce fragment seul, ce n'est pas un frame que vous voulez.** Un frame est un mini-navigateur, avec une adresse, un état de chargement et un historique. Sans adresse propre, il ne vous apporte rien et vous coûte le contrat d'identifiant.

Le test qui tranche pour les streams est celui de l'origine : **si personne n'a rien demandé, c'est un broadcast.** Si quelqu'un vient de cliquer et que plusieurs zones doivent changer, c'est une réponse `.turbo_stream`. Ce sont deux usages très différents qui partagent un format.

## Index par symptôme

| Symptôme | Cause la plus probable |
|---|---|
| Le formulaire part, rien ne bouge, console : « Form responses must redirect » | `200` avec du HTML sur un POST. Répondre `422` sur échec, `303` sur succès |
| Le formulaire part, rien ne bouge, **aucune erreur** | Un ancêtre `data-turbo="false"`, ou le formulaire est dans un frame que vous n'aviez pas vu |
| Le frame se vide, « Content missing » | La réponse n'a pas de `<turbo-frame>` de même id. Souvent une redirection vers `/login`. Marquer la page cible avec `turbo_page_requires_reload` |
| Le stream arrive, visible dans le réseau, aucun effet | La cible n'existe pas dans le DOM. C'est un no-op **totalement silencieux** |
| Le stream n'arrive pas sur un lien GET | Ajouter `data-turbo-stream` : sinon l'en-tête `Accept` n'est pas envoyé |
| `first child element must be a <template> element` | Un `<turbo-stream>` construit à la main sans son `<template>` |
| Le `Content-Type` est mauvais et il ne se passe rien du tout | Turbo teste `startsWith("text/vnd.turbo-stream.html")`. Sinon il n'intercepte pas, et la réponse repart dans le traitement normal, sans erreur |
| Le JS s'arrête de fonctionner après une navigation | Initialisation sur `DOMContentLoaded`, qui ne se déclenche qu'au premier chargement. Passer à `turbo:load` ou à Stimulus |
| Le JS s'arrête **seulement depuis qu'on a activé le morphing** | Les `<script>` inline ne se réexécutent pas, et `turbo:before-cache` ne se déclenche plus |
| Le morphing « ne marche pas », la page se remplace | `<%%= yield :head %>` manquant dans le layout : `turbo_refreshes_with` n'écrit rien sans lui |
| Le morphing ne se déclenche pas après un formulaire | La requête doit partir **et arriver sur la même URL** |
| Rechargement complet du navigateur à chaque navigation | Divergence de signature `data-turbo-track="reload"`. Normal après un déploiement. En boucle, chercher un asset injecté dynamiquement ou un service worker |
| Les listes s'animent n'importe comment sous morph | Pas d'`id` stable sur les éléments, ou un `id` dupliqué ailleurs dans le document, ou la balise a changé |
| Ça marche en Chrome, ça casse en Safari, sous morph | `moveBefore` contre `insertBefore` dans le garde-manger d'idiomorph. Ce n'est pas votre code |
| Le texte que l'utilisateur tape est effacé | `syncInputValue` écrit les propriétés vivantes, et Turbo n'active pas `ignoreActiveValue` |
| Le bouton retour affiche un contenu périmé | Comportement voulu : l'instantané en cache est rendu d'abord. `turbo-cache-control: no-cache` si le contenu est sensible |
| Le bouton retour affiche un widget en double | L'instantané a gardé le DOM injecté par le widget. Le détruire dans `disconnect()`, pas dans `turbo:before-cache` si vous morphez |
| Les messages flash n'apparaissent pas sous morph | Un broadcast refresh ne porte aucun flash. Et si le flash a le même `id` et le même texte, le morph ne modifie rien du tout |
| Un broadcast met à jour la page du mauvais utilisateur | Nom de flux non cloisonné. Signer n'est pas autoriser |
| Un broadcast arrive deux fois chez l'auteur | Variante synchrone sans `request_id`, ou broadcast depuis un job |
| Les mises à jour temps réel s'arrêtent, sans erreur | Le WebSocket est tombé. Action Cable ne rejoue rien. Rattraper sur l'attribut `connected` |
| Rien ne se passe quand le réseau est coupé, dans un frame ou un formulaire | `turbo:fetch-request-error` est émis mais personne ne l'écoute |
| La page d'erreur du navigateur apparaît quand le réseau est coupé, sur un clic de lien | Une visite Drive en échec réseau déclenche un rechargement complet. `preventDefault()` sur `turbo:fetch-request-error` l'empêche |
| Les modifications ne sont jamais diffusées depuis un `rails runner` | Le processus se termine avant la tâche anti-rebond programmée à 0,5 s |

## Ce qui a changé récemment

Si vous relisez de la documentation ou des billets antérieurs à 2026, méfiez-vous de ces points, tous modifiés dans la version 8.0.21 de janvier 2026 :

- `data-turbo-cache="false"` a été **supprimé**. C'est `data-turbo-temporary`.
- `Turbo.clearCache()` a été **supprimé**. C'est `Turbo.cache.clear()`.
- `data-turbo-frame="_parent"` a été **ajouté**.
- Les attributs `method` et `scroll` sur l'action de stream `refresh` ont été ajoutés : ils écrasent les balises meta, broadcast par broadcast.
- `before` et `after` dédoublonnent désormais les frères, comme `append` et `prepend` le faisaient déjà pour les enfants.

Et quelques éléments présents dans le code mais absents de la référence officielle, qui valent d'être connus : l'événement `turbo:before-frame-morph`, les options `Turbo.config.forms.mode`, `Turbo.config.forms.submitter`, `Turbo.config.drive.enabled` et `Turbo.config.drive.unvisitableExtensions`, et le fait que `turbo:frame-render` soit déclaré annulable alors que l'annuler ne produit aucun effet (la valeur de retour est jetée).

Enfin, les versions de référence de cet article sont figées à Turbo 8.0.23 et turbo-rails 2.0.23. Presque tout ce qui précède a été vérifié en lisant le code de ces deux versions plutôt que la documentation, précisément parce que c'est là que sont les écarts.
