---
title: Turbo n’a pas trois outils, il a trois scopes
description: Une grille simple pour choisir entre Drive, Frames et Streams, puis diagnostiquer
  le cache, le morphing et les broadcasts dans Turbo 8.0.23.
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 scopes

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 :

<ul class="legende-tags">
  <li><span class="tag tag-api">API</span>. Documenté sur <code>turbo.hotwired.dev</code>. Vous pouvez compter dessus : le modifier de façon incompatible constituerait un breaking change assumé.</li>
  <li><span class="tag tag-observe">Vérifié</span>. Présent dans le code ou les tests de la version épinglée, mais absent de l'API publique. À couvrir chez vous et à revalider après une montée de version.</li>
  <li><span class="tag tag-interne">Interne</span>. Détail d'implémentation. Précieux pour comprendre un bug, dangereux comme fondation. Ne bâtissez rien dessus.</li>
</ul>

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

<nav class="sommaire" aria-label="Sommaire de l'article">
  <p class="sommaire-titre">Au programme</p>
  <ol>
    <li><a href="#le-modèle-en-une-page">Le modèle en une page</a><span>les trois scopes, et qui désigne la cible</span></li>
    <li><a href="#quelle-primitive-choisir-">Quelle primitive choisir ?</a><span>le tableau de décision, et les deux tests qui tranchent</span></li>
    <li><a href="#turbo-drive">Turbo Drive</a><span>ce qui est intercepté, le cycle de vie, le cache, la config</span></li>
    <li><a href="#turbo-frames">Turbo Frames</a><span>la règle d’identifiant, le layout, les attributs qui comptent</span></li>
    <li><a href="#turbo-streams">Turbo Streams</a><span>les deux mondes, le trajet controller-vue, les huit actions</span></li>
    <li><a href="#le-statut-http-et-pourquoi-422">Le statut HTTP, et pourquoi 422</a></li>
    <li><a href="#le-morphing">Le morphing</a><span>ce qu’il enlève, idiomorph, la pantry, la valeur des champs</span></li>
    <li><a href="#les-broadcasts">Les broadcasts</a><span>les macros, le request-id, le debounce, la sécurité des flux</span></li>
    <li><a href="#stimulus-et-les-bibliothèques-tierces">Stimulus et les bibliothèques tierces</a></li>
    <li><a href="#réseau-et-hors-ligne">Réseau et hors-ligne</a><span>échecs de fetch, service workers, Action Cable</span></li>
    <li><a href="#checklist-de-debug">Checklist de debug</a><span>dans cet ordre, avant d’ouvrir un ticket</span></li>
    <li><a href="#index-par-symptôme">Index par symptôme</a><span>vous avez un bug, vous partez d’ici</span></li>
    <li><a href="#ce-qui-a-changé-récemment">Ce qui a changé récemment</a></li>
  </ol>
  <p class="sommaire-note">Si vous êtes ici parce que quelque chose ne marche pas, allez directement aux points 11 et 12, puis remontez vers la section concernée.</p>
</nav>

## Le modèle en une page

<figure class="schema">
  <img width="940" height="562" src="/images/posts/turbo/01-trois-scopes.svg?v=20260809-signature" alt="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." />
  <figcaption>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.</figcaption>
</figure>

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**{: .callout-label}
>
> 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.
{: .callout .callout-retenir}

## 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é | <span class="mark-yes" role="img" aria-label="oui">✓</span> | Annuler pour laisser le navigateur faire une navigation classique |
| `turbo:before-visit` | `<html>` | <span class="mark-yes" role="img" aria-label="oui">✓</span> | 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 | <span class="mark-yes" role="img" aria-label="oui">✓</span> | `preventDefault()` **ne bloque pas la requête, il la met en pause** jusqu'à l'appel de `detail.resume()` |
| `turbo:visit` | `<html>` | <span class="mark-no" role="img" aria-label="non">✕</span> | 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 | <span class="mark-yes" role="img" aria-label="oui">✓</span> | 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>` | <span class="mark-no" role="img" aria-label="non">✕</span> | Nettoyer le DOM avant le cache. Le clone est différé au prochain event loop tick |
| `turbo:before-render` | `<html>` | <span class="mark-yes" role="img" aria-label="oui">✓</span> | Même sémantique de pause. `detail.newBody` est modifiable avant rendu, `detail.renderMethod` vaut `replace` ou `morph` |
| `turbo:render` | `<html>` | <span class="mark-no" role="img" aria-label="non">✕</span> | Le nouveau corps est en place |
| `turbo:load` | `<html>` | <span class="mark-no" role="img" aria-label="non">✕</span> | 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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/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 | <span class="mark-yes" role="img" aria-label="oui">✓</span> | 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 | <span class="mark-no" role="img" aria-label="non">✕</span> | Déclaré `cancelable: true`, mais la valeur de retour du dispatch est jetée : l'annuler ne produit rien |
| `turbo:frame-load` | le frame | <span class="mark-no" role="img" aria-label="non">✕</span> | Le frame a fini |
| `turbo:submit-start` | le `<form>` | <span class="mark-no" role="img" aria-label="non">✕</span> | `detail.formSubmission` |
| `turbo:submit-end` | le `<form>` | <span class="mark-no" role="img" aria-label="non">✕</span> | 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**{: .callout-label} <span class="tag tag-observe">vérifié</span>
>
> `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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/http/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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/tests/functional/form_submission_tests.js#L191-L204)), ce qui en fait une garantie plus solide qu'il n'y paraît.
{: .callout .callout-capot}

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**{: .callout-label} <span class="tag tag-interne">interne</span>
>
> 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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L377-L383)). Or `loadCachedSnapshot` calcule sa preview par `const isPreview = this.shouldIssueRequest()` ([`visit.js#L245-L265`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L245-L265)). Pas de requête, donc pas de preview, donc un seul rendu.
{: .callout .callout-capot}

En pratique, la guard clause à écrire dans un contrôleur Stimulus reste la même :

```js
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**{: .callout-label}
>
> **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.
{: .callout .callout-retenir}

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**{: .callout-label} <span class="tag tag-interne">interne</span>
>
> 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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/frames/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.
{: .callout .callout-capot}

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.

```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) => window.confirm(message)
```

> **Sous le capot**{: .callout-label} <span class="tag tag-observe">vérifié</span>
>
> Sur ces cinq lignes, deux sont documentées sur [la page Drive de la référence](https://turbo.hotwired.dev/reference/drive) : `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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/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.
{: .callout .callout-capot}

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

```js
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 :

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

<figure class="schema">
  <img width="940" height="646" src="/images/posts/turbo/02-contrat-frame.svg?v=20260809-signature" alt="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." />
  <figcaption>Après <code>turbo-visit-control</code>, Turbo cherche le frame directement ou via <code>recurse</code>. Sans correspondance, il émet <code>turbo:frame-missing</code>.</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. En pratique, laissez le serveur reprendre l'identifiant envoyé par le client avec le helper `turbo_frame_request_id` de turbo-rails :

```erb
<%%= 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 :

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

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

```ruby
layout :layout_for_request

private

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

> **Piège**{: .callout-label}
>
> 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.
{: .callout .callout-piege}

### 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**{: .callout-label} <span class="tag tag-interne">interne</span>
>
> 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`.
>
> ```js
> if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) {
>   this.sourceURL = fetchResponse.response.url
> }
> ```
>
> ([`frame_controller.js#L132-L135`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/frames/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.
{: .callout .callout-capot}

**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>` :

```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 explique une bonne partie des erreurs.

<figure class="schema">
  <img width="1000" height="796" src="/images/posts/turbo/03-stream-vs-broadcast.svg?v=20260809-signature" alt="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." />
  <figcaption>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.</figcaption>
</figure>

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**{: .callout-label}
>
> 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>`.
{: .callout .callout-retenir}

### 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 :

```ruby
# 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 :

```erb
<%%# 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`.

```erb
<%%# 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>
```

```erb
<%%# 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 | <span class="mark-yes" role="img" aria-label="oui">✓</span> |
| `prepend` | Ajoute au début | <span class="mark-yes" role="img" aria-label="oui">✓</span> |
| `before` | Insère avant la cible | <span class="mark-yes" role="img" aria-label="oui">✓</span> |
| `after` | Insère après la cible | <span class="mark-yes" role="img" aria-label="oui">✓</span> |
| `replace` | Remplace la cible elle-même | <span class="mark-yes" role="img" aria-label="oui">✓</span> |
| `update` | Remplace le contenu de la cible | <span class="mark-yes" role="img" aria-label="oui">✓</span> |
| `remove` | Supprime la cible. Pas de `<template>` | <span class="mark-yes" role="img" aria-label="oui">✓</span> |
| `refresh` | Lance un refresh Turbo sur l'URL courante, sous forme de visite `replace`. Pas de `<template>` | <span class="mark-no" role="img" aria-label="non">✕</span> |

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

```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, 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 :

```ruby
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**{: .callout-label} <span class="tag tag-interne">interne</span>
>
> 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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/elements/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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/elements/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.
{: .callout .callout-debug}

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 :

```ruby
# 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>` :

```js
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 :

```ruby
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 :

```js
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à :

```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 : il exprime sans ambiguïté « cette mutation est terminée, récupère maintenant la ressource cible en GET ». Dans Fetch :

- **303** remplace toute méthode autre que `GET` ou `HEAD` par `GET`.
- **301** et **302** ne forcent le passage en GET **que pour une requête HTTP POST**. Une vraie requête HTTP `DELETE` redirigée en 302 serait rejouée en `DELETE` sur l'URL cible.
- **307** et **308** préservent toujours la méthode.

Mais **turbo-rails 2.0.23 n'envoie pas ces méthodes comme telles**. Son listener `encodeMethodIntoRequestBody` transforme toute méthode non-GET en requête HTTP `POST` et place la méthode logique dans `_method` ([`fetch_requests.js#L1-L18`](https://github.com/hotwired/turbo-rails/blob/v2.0.23/app/javascript/turbo/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**{: .callout-label} <span class="tag tag-interne">interne</span>
>
> Sur une visite, le test est binaire : `if (isSuccessful(statusCode) && responseHTML != null)` rend normalement, `else` part sur `this.view.renderError(...)` ([`visit.js#L203-L223`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L203-L223)), avec `isSuccessful` défini comme `statusCode >= 200 && statusCode < 300` ([`visit.js#L417-L419`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L417-L419)). La distinction 4xx/5xx n'existe que dans `Navigator#formSubmissionFailedWithResponse` ([`navigator.js#L92-L107`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/navigator.js#L92-L107)).
{: .callout .callout-capot}

Ça mérite mieux qu'une note de bas de page, parce que `ErrorRenderer` est nettement plus brutal que `PageRenderer` :

- Il fait un `replaceChild` sur le `<head>` **entier**, au lieu de le fusionner.
- Il parcourt **tous** les `<script>` du document, y compris ceux du `<head>` ([`error_renderer.js#L36-L38`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/error_renderer.js#L36-L38)), puis réactive ceux qui n'ont pas `data-turbo-eval="false"` ([`util.js#L1-L15`](https://github.com/hotwired/turbo/blob/v8.0.23/src/util.js#L1-L15)).
- Il ne fait pas tourner Bardo, la machinerie qui transplante les éléments persistants. **`data-turbo-permanent` n'est pas honoré sur une page d'erreur.**

Et symétriquement, comme un 4xx de formulaire passe par `PageRenderer`, il est soumis à `shouldRender` : une divergence `data-turbo-track="reload"` sur votre page d'erreur transformera l'affichage des erreurs de validation en rechargement complet du navigateur.

> **Piège**{: .callout-label}
>
> 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.
{: .callout .callout-piege}

## 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 :

```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 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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/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"` | <span class="mark-no" role="img" aria-label="non">✕</span> 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é | <span class="mark-no" role="img" aria-label="non">✕</span> `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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/tests/functional/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**{: .callout-label} <span class="tag tag-interne">interne</span>
>
> `Session#refresh` force `shouldCacheSnapshot: false` ([`session.js#L108-L117`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/native/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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L245-L263), [`page_view.js#L43-L50`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/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.
{: .callout .callout-capot}

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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/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à.

<figure class="schema">
  <img width="1000" height="788" src="/images/posts/turbo/04-idiomorph.svg?v=20260809-signature" alt="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." />
  <figcaption>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.</figcaption>
</figure>

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**{: .callout-label}
>
> **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.
{: .callout .callout-retenir}

### Les déplacements de nœuds, et pourquoi Safari diffère

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

Lorsqu'idiomorph réutilise ailleurs un nœud à `id` persistant, il le déplace sans le cloner. Selon l'ordre du morph, le déplacement se fait directement depuis l'arbre courant ou passe temporairement par la pantry, un `<div>` caché après `<body>`. Dans les deux cas, idiomorph utilise `moveBefore()` lorsqu'il est disponible, avec `insertBefore()` en fallback.

Le choix entre les deux change le comportement observable :

- Avec `moveBefore` (Chromium 133 et plus, Firefox 144 et plus), le nœud **reste connecté au niveau du DOM**. Les `<iframe>` ne rechargent pas, l'état des transitions est conservé et les `connectedCallback` natifs ne rejouent pas. Stimulus observe malgré tout les mutations de déplacement et exécute `disconnect()` puis `connect()`.
- Avec `insertBefore`, le nœud est **réellement déconnecté puis reconnecté**. Les `<iframe>` rechargent, les transitions et animations CSS peuvent être interrompues ou relancées, les `connectedCallback` des custom elements rejouent, et Stimulus exécute aussi `disconnect()` puis `connect()`. La lecture d'un `<video>` ou d'un `<audio>` survit normalement au retrait et à la réinsertion ; ce n'est pas une différence entre les deux chemins.

Et voici la partie que j'ai vue mal comprise plus d'une fois. On présente volontiers `moveBefore` comme « largement disponible en 2026 ». Ça n'est vrai qu'en excluant Apple.

> **Sous le capot**{: .callout-label} <span class="tag tag-observe">vérifié</span>
>
> 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](https://bugs.webkit.org/show_bug.cgi?id=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](https://developer.apple.com/support/alternative-browser-engines/) : 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.
{: .callout .callout-capot}

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 :

```js
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**{: .callout-label}
>
> 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.
{: .callout .callout-piege}

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 :

```js
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 :

```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 (turbo:morph-element)
document.addEventListener("turbo:morph-element", ({ target }) => { /* … */ })
```

```erb
<%%# 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**{: .callout-label} <span class="tag tag-interne">interne</span>
>
> 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`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/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**.
{: .callout .callout-capot}

**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

```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 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**{: .callout-label}
>
> 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.
{: .callout .callout-retenir}

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`](https://github.com/hotwired/turbo-rails/blob/v2.0.23/app/helpers/turbo/streams/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**{: .callout-label}
>
> **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é.
{: .callout .callout-piege}

**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 :

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

`broadcast_refresh_later_to`, ainsi que les appels qui reçoivent déjà `html:` ou `content:`, n'ont rien à rendre et ne passent pas par ce renderer. Pour les autres, il s'agit d'un `ActionController::Renderer` avec un environnement Rack synthétique. Il n'y a ni session, ni cookies, ni clé Warden. Conséquences mécaniques :

- **Avec Devise standard, `current_user` lève `Devise::MissingWarden`.** Le helper appelle `warden`, absent de l'environnement synthétique. Un partial qui contient `if current_user.admin?` fait donc échouer le rendu du broadcast ; il ne rend pas silencieusement une branche anonyme. Un helper applicatif personnalisé peut avoir un autre comportement, mais il ne faut pas en dépendre ici.
- **`Current.*` n'est pas transmis à un worker asynchrone.** Un job réellement pris par la queue s'exécute dans un autre contexte, sans le state de la requête. `perform_now` et l'adapter `:inline` peuvent rester dans le même thread et donner l'impression inverse ; les variantes de broadcast synchrones rendent elles aussi dans le thread courant. Ne prenez pas ces chemins comme preuve qu'un worker verra `Current.*`.
- **`ApplicationController` est codé en dur, pas dépouillé de toute sa configuration.** Le renderer conserve les view paths configurés et, avec le réglage Rails par défaut `include_all_helpers`, les helpers applicatifs. En revanche, aucun `before_action` ne tourne et aucune ivar de requête n'est préparée. Un `helper_method` qui dépend du state de l'instance du controller reste donc inutilisable.
- **Le résultat des helpers `_url` dépend de `default_url_options` et du `default_env` du renderer.** Avec ce renderer Rails 8.0.2, l'absence de host configuré produit une URL synthétique sur `example.org`. Ne dépendez pas de ce fallback : configurez `Rails.application.routes.default_url_options[:host]` pour les broadcasts. Les helpers `_path`, eux, n'ont pas besoin de host ([`renderer.rb`, v8.0.2](https://github.com/rails/rails/blob/v8.0.2/actionpack/lib/action_controller/renderer.rb#L105-L110)).

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.

```erb
<%%# 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é

```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é. 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**{: .callout-label}
>
> **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.
{: .callout .callout-retenir}

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

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 :

```js
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**{: .callout-label}
>
> **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.
{: .callout .callout-retenir}

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

```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 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**{: .callout-label} <span class="tag tag-interne">interne</span>
>
> Le nom `reload` induit en erreur, et j'ai mis du temps à comprendre ce qui se passait vraiment :
>
> ```js
> reload(reason) {
>   dispatch("turbo:reload", { detail: reason })
>   window.location.href = (this.redirectedToLocation || this.location)?.toString() || window.location.href
> }
> ```
>
> ([`browser_adapter.js#L130-L134`](https://github.com/hotwired/turbo/blob/v8.0.23/src/core/native/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.
{: .callout .callout-capot}

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.

```js
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 :

```js
const isDocumentNavigation = ({ request }) => {
  const accept = request.headers.get('Accept') || ''

  return request.method === 'GET' && (
    request.mode === 'navigate' ||                                   // navigation navigateur
    (request.destination === '' &&                                   // fetch() de Turbo Drive
     !request.headers.has('Turbo-Frame') &&                           // pas une requête de frame
     !accept.includes('text/vnd.turbo-stream.html') &&                // pas un GET stream
     accept.includes('text/html'))
  )
}

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

Les deux exclusions sont indispensables : Frames et Streams utilisent eux aussi `fetch()`, portent `destination === ""` et acceptent du HTML. Sans elles, une réponse partielle peut polluer le cache de la même URL utilisée ensuite par Drive. La seconde route, elle, fonctionne telle quelle : ces requêtes sont bien initiées par le navigateur et portent un `destination` renseigné.

Ce matcher suppose aussi que vos autres `fetch()` applicatifs en GET ne demandent pas de HTML sans en-tête distinctif. Si c'est votre cas, ces métadonnées HTTP ne suffisent pas à reconnaître Drive : marquez vos requêtes Drive d'un en-tête applicatif depuis `turbo:before-fetch-request` et faites porter le prédicat dessus.

Deux pannes de service worker reviennent souvent avec Turbo : un mauvais `Content-Type` et des asset fingerprints incohérents. Ce ne sont pas les deux seules possibles.

**Servir une réponse au mauvais `Content-Type`.** Sur une visite Drive, Turbo enregistre `contentTypeMismatch`, émet `turbo:reload` puis fait une navigation pleine page vers la destination. Dans un frame, une réponse non HTML laisse le frame inchangé après les événements de fetch. Ce n'est silencieux que dans ce second contexte côté interface.

**Servir un HTML dont les asset fingerprints ne correspondent plus.** Turbo compare les éléments `data-turbo-track="reload"` entre snapshots. En cas de divergence, il déclenche un rechargement complet du navigateur. Une boucle n'apparaît que si les réponses suivantes continuent d'alterner entre des HTML et des assets incompatibles ; une entrée de cache périmée ne suffit pas, à elle seule, à garantir la boucle.

Rails 8 ajoute ses propres contraintes :

- **Placez le service worker à une URL racine stable.** Par défaut, son scope ne peut pas remonter au-dessus du répertoire de son URL. Le header HTTP `Service-Worker-Allowed` peut élargir ce scope, mais il ne règle pas l'URL instable d'un asset fingerprinté par Propshaft. Deux options simples : `public/service-worker.js`, ou la route que Rails 8 génère, qui est **commentée par défaut** dans `config/routes.rb` et qu'il faut donc décommenter : `get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker`. Notez le controller `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. Utilisez `importScripts()` dans un classic worker, ou enregistrez un module worker avec `{ type: "module" }` et des imports statiques résolus comme de vraies URL de modules, sans importmap.
- **Les URL d'assets fingerprintés changent avec leur contenu.** Un precache manifest écrit à la main périme au premier déploiement qui touche un fichier. Préférez le runtime caching, par `request.destination`.

Un patch officiel est en cours ([turbo#1427](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

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**{: .callout-label} <span class="tag tag-observe">vérifié</span>
>
> 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`](https://github.com/hotwired/turbo-rails/blob/v2.0.23/app/javascript/turbo/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 }`.
{: .callout .callout-capot}

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 :

```js
document.addEventListener("turbo:before-morph-attribute", (event) => {
  if (event.target.matches("turbo-cable-stream-source") &&
      event.detail.attributeName === "connected") {
    event.preventDefault()
  }
})

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

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

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

Avec `turbo_refreshes_with method: :morph, scroll: :preserve`, ce rattrapage coûte un aller-retour et préserve le scroll. Le focus survit si son nœud reste en place ; idiomorph peut aussi le restaurer sur un `<input>` ou un `<textarea>` identifié. **C'est ce qui rend le couple morphing + broadcasts praticable** : le rattrapage devient assez bon marché pour qu'on l'écrive.

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

## Checklist de debug

Cette checklist résout presque tous les bugs Turbo que j'ai rencontrés. Elle est volontairement mécanique : chaque étape élimine une classe de causes, et les trois premières prennent dix secondes.

**1. La console, avant le réseau.** Un `console.error` change complètement le diagnostic. `Form responses must redirect` → statut HTTP. `unknown action` ou `target or targets attribute is missing` → le `<turbo-stream>` est mal formé. `Content missing` → identifiant de frame. **Console vide et rien à l'écran :** cherchez d'abord un no-op silencieux, une target absente ou un événement annulé. Cela oriente le diagnostic, mais ne prouve pas que la réponse était vide.

**2. La requête est-elle partie, et avec quels en-têtes.** Dans l'onglet Réseau, vérifiez `Accept`, `Turbo-Frame` et `X-Turbo-Request-Id`. L'absence de `text/vnd.turbo-stream.html` dans `Accept` empêche la négociation habituelle du format, mais une extension `.turbo_stream` ou un `params[:format]` explicite peut encore le sélectionner. Regardez donc aussi l'URL et les params. Si la requête n'est pas partie du tout, cherchez un `data-turbo="false"` sur un ancêtre.

**3. Le statut, et seulement à ce moment-là.** 200 sans redirection sur un POST est le classique. Un Content-Type `turbo-stream` choisit le stream renderer avant que Turbo consulte les page/frame renderers, mais le statut alimente toujours `turbo:submit-end.detail.success`. Pour une navigation de frame lancée par link ou `src`, 2xx et 4xx/5xx HTML passent tous par `loadResponse()`. Pour une réponse HTML ordinaire à une frame form, le statut choisit la branche success ou failure. Il détermine aussi le frame qui reçoit la réponse et le vidage éventuel du cache Drive.

**4. La cible existe-t-elle vraiment, à cet instant.** Dans la console, `document.getElementById("votre_id")`. Vérifiez aussi qu'elle n'est pas dans un `<template>`, dans une `<iframe>`, ou dans un frame `loading="lazy"` pas encore chargé : trois endroits où Turbo ne regardera pas.

**5. Combien y en a-t-il ?** `document.querySelectorAll("#votre_id").length`. La réponse doit être `1`. `2` explique à la fois les streams qui visent le mauvais élément et les morphs qui se comportent bizarrement à distance.

**6. Quel renderer a tourné.** `document.addEventListener("turbo:before-render", e => console.log(e.detail.renderMethod))`. `MorphingPageRenderer` exige une méthode effective `morph` et un page refresh au sens de `PageView`. Quand il existe une Visit, cela signifie `action: "replace"` et le même `pathname` que la dernière page rendue ; sans Visit, `isPageRefresh()` vaut directement `true`. Une soumission pleine page réussie crée une Visit, tandis qu'une réponse en échec prend le chemin de rendu direct. Un `<%%= yield :head %>` manquant fait disparaître la meta `morph`, mais ce n'est qu'une des causes possibles.

**7. Est-ce que ça vient d'un broadcast.** Regardez si le HTML reçu est le même pour tout le monde. Avec Devise standard, un partial diffusé qui appelle `current_user` échoue faute de Warden ; un helper personnalisé peut produire un autre résultat, tout aussi dépendant d'un contexte absent. Rendez un HTML neutre ou passez des locals explicites.

**8. Le même test dans Safari, ou sur un iPhone.** Une différence limitée à ces navigateurs justifie de vérifier `Element.prototype.moveBefore` et le fallback `insertBefore` d'idiomorph. Ce n'est pas une preuve : le code applicatif, le CSS et d'autres écarts d'API restent des suspects. Réduisez le cas avant d'innocenter l'application.

**9. Coupez le réseau, et regardez ce qui se passe.** C'est le seul moyen de découvrir qu'un clic de lien hors ligne quitte votre application, et que personne n'écoute `turbo:fetch-request-error`.

## Index par symptôme

| Symptôme | Cause la plus probable |
|---|---|
| Le formulaire part, rien ne bouge, console : « Form responses must redirect » | `200` avec du HTML sur un POST. Répondre `422` sur échec, `303` sur succès |
| Le formulaire met à jour une zone inattendue | Il hérite du frame qui le contient, de son `target`, ou d'un `data-turbo-frame` sur le formulaire ou le bouton |
| Le frame se vide, « Content missing » | La réponse n'a pas de `<turbo-frame>` de même id. Souvent une redirection vers `/login`. Marquer la page cible avec `turbo_page_requires_reload` |
| Le stream arrive, visible dans le réseau, aucun effet | La cible n'existe pas dans le DOM. C'est un no-op **totalement silencieux** |
| Le stream n'arrive pas sur un lien GET | Ajouter `data-turbo-stream` : sinon l'en-tête `Accept` n'est pas envoyé |
| `first child element must be a <template> element` | Un `<turbo-stream>` construit à la main sans son `<template>` |
| Le `Content-Type` est mauvais | Un frame reste inchangé après les événements de fetch ; une visite Drive émet `turbo:reload` puis navigue en pleine page vers la destination |
| Le JS s'arrête de fonctionner après une navigation | Initialisation sur `DOMContentLoaded`, qui ne se déclenche qu'au premier chargement. Passer à `turbo:load` ou à Stimulus |
| Le JS s'arrête **seulement depuis qu'on a activé le morphing** | Les `<script>` inline ne se réexécutent pas. `turbo:before-cache` dépend en plus du chemin : absent après une soumission non-GET réussie ; absent sur `Session#refresh`, sauf si un snapshot previewable de l'URL courante existe déjà et que le document courant est cacheable |
| Le morphing « ne marche pas », la page se remplace | Sur un page refresh, la méthode effective n'est pas `morph`. Vérifier la meta émise par `turbo_refreshes_with`, donc `<%%= yield :head %>`, ou l'attribut `method` du stream `refresh` |
| Le morphing ne se déclenche pas après un formulaire | Pour une réponse pleine page réussie transformée en Visit, il faut une méthode effective `morph`, `action: "replace"` et le même `pathname`. Sans action explicite, Turbo ne choisit `replace` que pour une redirection vers l'URL complète de départ ; sinon il choisit `advance`. Un 4xx pleine page passe directement par `renderPage`, un 5xx par `ErrorRenderer`, et une soumission de frame suit le renderer du frame : ces branches n'appliquent pas ce test de Visit |
| Rechargement complet du navigateur à chaque navigation | Divergence de signature `data-turbo-track="reload"`. Normal après un déploiement. Une boucle demande que les réponses successives restent incohérentes ; chercher un asset injecté dynamiquement ou un cache de service worker qui mélange les versions |
| Les listes s'animent n'importe comment sous morph | Pas d'`id` stable sur les éléments, ou un `id` dupliqué dans l'une des racines de ce morph, ou la balise a changé |
| Ça marche sur votre poste, ça casse sur iPhone, sous morph | Vérifier la présence de `Element.prototype.moveBefore`. Le fallback `insertBefore` peut déconnecter puis reconnecter les nœuds déplacés ; les navigateurs WebKit partagent souvent ce chemin, mais ce seul symptôme ne prouve ni la cause ni l'innocence du code applicatif |
| Le texte que l'utilisateur tape est effacé | `syncInputValue` écrit les valeurs courantes des propriétés DOM, et Turbo n'active pas `ignoreActiveValue` |
| Le bouton retour affiche un contenu périmé | Avec un snapshot de restauration utilisable, Turbo rend ce snapshot une seule fois et ne lance aucun fetch. Sans snapshot utilisable, il repart sur le réseau. `turbo-cache-control: no-cache` si le contenu est sensible |
| Le bouton retour affiche un widget en double | Le snapshot a gardé le DOM injecté. Le nettoyer dans `turbo:before-cache`, le hook déterministe prévu pour cela. Un `disconnect()` synchrone peut aussi précéder le clone différé, mais ne doit pas être votre seul contrat de cache |
| Les messages flash n'apparaissent pas sous morph | Un broadcast refresh ne porte aucun flash. Et si le flash a le même `id` et le même texte, le morph ne modifie rien du tout |
| Un broadcast met à jour la page du mauvais utilisateur | Nom de flux non cloisonné ou canal sans autorisation. Signer n'est pas autoriser |
| Un broadcast arrive deux fois chez l'auteur | `request_id` absent ou nul, broadcast hors requête, ou identifiant évincé après vingt nouvelles requêtes Turbo |
| Les mises à jour temps réel s'arrêtent, sans erreur | Le WebSocket est tombé. Action Cable ne rejoue rien. Rattraper sur l'attribut `connected`, après l'avoir protégé du morph |
| Rien ne se passe quand le réseau est coupé, dans un frame ou un formulaire | `turbo:fetch-request-error` est émis mais personne ne l'écoute |
| La page d'erreur du navigateur apparaît quand le réseau est coupé, sur un clic de lien | Une visite Drive en échec réseau navigue vers la destination, hors Turbo. `preventDefault()` sur `turbo:fetch-request-error` est le seul moyen de l'empêcher : `turbo:reload` n'est pas annulable |
| Après le 404 d'un formulaire, le frame affiche l'erreur mais `reload()` revient à l'ancien contenu | Une réponse de formulaire en échec ne remplace pas un `src` déjà présent. Une navigation lancée par un lien ou par `src`, elle, a fixé cette URL avant la requête : `reload()` rejoue alors l'URL en erreur |
| Après une erreur 500 sur un clic de lien, l'application devient instable | `ErrorRenderer` remplace tout le `<head>`, réactive les scripts sauf avec `data-turbo-eval="false"` et ne fait pas tourner Bardo : `data-turbo-permanent` n'est pas honoré |
| Le retour arrière est lent depuis qu'un formulaire de recherche existe | Un échec GET avec réponse HTML vide le cache Drive ; dans un frame, tout échec de formulaire le vide |
| Certaines modifications ne sont pas diffusées depuis un `rails runner` | Le processus est sorti avant la tâche de debounce programmée à 0,5 s. S'il reste vivant au-delà du délai, le broadcast part |

## Ce qui a changé récemment

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

- `data-turbo-cache="false"` a été **supprimé**. C'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, tous à ranger sous l'étiquette <span class="tag tag-observe">vérifié</span> : 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](https://sxnlabs.com/contact/?ref=turbo-reference). Je pars d'un cas reproductible, corrige le contrat rompu et vérifie le résultat en production.
