<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="fr">
  <generator uri="https://bridgetownrb.com/" version="2.2.2">Bridgetown</generator>
  <link href="https://sxnlabs.com/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://sxnlabs.com/" rel="alternate" type="text/html" hreflang="fr" />
  <updated>2026-09-15T14:42:59+02:00</updated>
  <id>https://sxnlabs.com/feed.xml</id>
  <title type="html">SXN Labs</title>
  <subtitle>SXN Labs digitalise les TPE/PME : logiciels métier sur mesure en Ruby on Rails, approche terrain, 20 ans d&#39;expérience. Basé à Brest, intervient partout en France.</subtitle>
  <author>
    <name>Nathan Le Ray</name>
    <uri>https://www.linkedin.com/in/nathanleray/</uri>
  </author>
  <entry xml:lang="fr">
    <title type="html">Ce que l&#39;IA n&#39;a pas rendu plus rapide</title>
    <link href="https://sxnlabs.com/opinion/2026/10/22/ce-que-l-ia-n-a-pas-rendu-plus-rapide/" rel="alternate" type="text/html" title="Ce que l&#39;IA n&#39;a pas rendu plus rapide" />
    <published>2026-10-22T09:00:00+02:00</published>
    <updated>2026-10-22T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/opinion/2026/10/22/ce-que-l-ia-n-a-pas-rendu-plus-rapide/</id>
    <content type="html" xml:base="https://sxnlabs.com/opinion/2026/10/22/ce-que-l-ia-n-a-pas-rendu-plus-rapide/">&lt;p&gt;Depuis que l’IA écrit une bonne part de mon code, mes journées ne sont pas plus courtes. Elles ont changé de contenu : écrire le code prend beaucoup moins de temps, et tout ce qui l’entoure a pris la place.&lt;/p&gt;

&lt;h2 id=&quot;ce-qui-est-allé-plus-vite&quot;&gt;Ce qui est allé plus vite&lt;/h2&gt;

&lt;p&gt;Le premier jet d’un écran, une migration, les tests d’une règle métier déjà claire, une traduction, la documentation d’une API, la lecture d’un repo qu’on n’a pas ouvert depuis six mois. Sur ces tâches, le gain est net, parfois du simple au triple à vue de nez. Mais c’est la partie du métier qu’on voit, et la facture se remplit ailleurs.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;1000&quot; height=&quot;350&quot; src=&quot;/images/posts/ia-plus-rapide/etapes-logiciel.fr.svg&quot; alt=&quot;Cinq étapes enchaînées : décider quoi construire, spécifier le comportement attendu, écrire le code, vérifier ce qui sort et ce qu&#39;il expose, répondre de la production. Seule l&#39;étape écrire est marquée plus rapide avec l&#39;IA. Les quatre autres prennent autant de temps qu&#39;avant.&quot; /&gt;
  &lt;figcaption&gt;L&#39;IA a raccourci une étape sur cinq, celle qu&#39;on voit.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;décider&quot;&gt;Décider&lt;/h2&gt;

&lt;p&gt;Sur mon outil de prospection, un modèle note les entreprises selon la probabilité qu’elles m’achètent un logiciel. Il donnait presque la même note haute à tout le monde. Réécrire le prompt m’a pris une soirée.&lt;/p&gt;

&lt;p&gt;Le reste a pris des jours : choisir quelle erreur me coûte le plus, un bon prospect jamais vu ou un mauvais qui me fait perdre une heure, puis trier à la main des dizaines de sociétés pour avoir de quoi mesurer. Aucun assistant ne prend cette décision, parce qu’il ne sait pas ce qui vous coûte.&lt;/p&gt;

&lt;h2 id=&quot;spécifier&quot;&gt;Spécifier&lt;/h2&gt;

&lt;p&gt;Sur un logiciel de gestion, une entreprise de démonstration voyait s’afficher « votre abonnement est résilié », parce que le code ne distinguait pas « démo » de « arrêté ». La modification tenait en une condition et un message, cinq minutes.&lt;/p&gt;

&lt;p&gt;La demi-heure d’avant a servi à répondre à ce que le code ne demande pas. Que doit lire une entreprise en démonstration ? Qui peut changer ce statut ? Que voit un administrateur qui usurpe le compte d’un collègue ? L’IA écrit très bien le comportement qu’on lui décrit, pas celui dont vous avez besoin.&lt;/p&gt;

&lt;h2 id=&quot;vérifier&quot;&gt;Vérifier&lt;/h2&gt;

&lt;p&gt;Un assistant produit du code qui a l’air juste, et c’est ce qui le rend trompeur.&lt;/p&gt;

&lt;p&gt;Une pull request corrige un bug et cite deux issues voisines comme dettes non traitées, avec un lien vers chacune. Une automatisation qui ferme « les issues liées » les ferme toutes les deux, et deux bugs toujours présents disparaissent du suivi.&lt;/p&gt;

&lt;p&gt;Une version de ma gem de facturation électronique passait ses 317 tests et ne se chargeait pas chez qui n’avait pas déjà deux de ses dépendances. Aucun outil ne m’a signalé le contrôle qui manquait. Il fallait savoir que les tests tournent dans le repo, et que l’utilisateur installe le package.&lt;/p&gt;

&lt;h2 id=&quot;sécuriser&quot;&gt;Sécuriser&lt;/h2&gt;

&lt;p&gt;Cet été, les fuites se sont enchaînées. &lt;a href=&quot;https://www.impots.gouv.fr/actualite/vol-de-donnees-suite-des-acces-illegitimes-au-systeme-dinformation-de-la-dgfip&quot;&gt;La DGFiP a confirmé&lt;/a&gt; des accès illégitimes à son système d’information entre juin et août, avec des identifiants usurpés, et &lt;a href=&quot;https://www.jechange.fr/banques/news/fuite-donnees-dgfip-4750-informaticiens&quot;&gt;678 000 personnes seraient concernées&lt;/a&gt; selon le ministère. &lt;a href=&quot;https://www.lemondeinformatique.fr/actualites/lire-le-ministere-de-l-education-nationale-cible-par-une-attaque-100733.html&quot;&gt;L’Éducation nationale&lt;/a&gt; a subi une intrusion dans sa plateforme de formation des personnels, qui a pu exposer les données de ses agents en poste depuis 2001. &lt;a href=&quot;https://www.igen.fr/telecoms/2026/08/sfr-confirme-une-fuite-de-donnees-touchant-ses-clients-fibre-plus-de-2-millions-de-lignes-revendiquees-157593&quot;&gt;SFR&lt;/a&gt; a prévenu en août des clients Fibre dont les coordonnées ont été lues via un outil interne. Sur l’année 2025, la CNIL a reçu &lt;a href=&quot;https://www.cnil.fr/fr/rapport-annuel-2025&quot;&gt;6 167 notifications de violations de données&lt;/a&gt;, 9,5 % de plus qu’en 2024.&lt;/p&gt;

&lt;p&gt;Beaucoup de ces fuites ne demandent aucun talent. Au printemps, &lt;a href=&quot;https://x-pression.media/faille-idor-comment-une-vulnerabilite-basique-aurait-permis-la-fuite-massive-de-donnees-de-lants&quot;&gt;11,7 millions de comptes de l’ANTS&lt;/a&gt; ont été exposés par une faille attribuée à une IDOR : il suffisait de changer le numéro d’un dossier dans l’URL pour lire celui de quelqu’un d’autre, parce que le serveur ne vérifiait pas à qui il appartenait.&lt;/p&gt;

&lt;p&gt;C’est exactement le genre de code qui a l’air juste. L’écran fonctionne, les tests passent, et la faille ne se voit qu’une fois exploitée. Relire chaque changement en cherchant qui pourrait en abuser prend autant de temps qu’avant, et produire plus de code en produit davantage à relire.&lt;/p&gt;

&lt;h2 id=&quot;répondre-de-la-production&quot;&gt;Répondre de la production&lt;/h2&gt;

&lt;p&gt;Lors d’un audit récent sur un logiciel, les corrections de code ont pris une fraction du temps d’il y a deux ans. Ce qui reste sur ma liste, ce sont des opérations d’exploitation qu’on ne rejoue pas, faites à la main par quelqu’un qui en répond. Et quand la production tombe un samedi soir, le modèle n’est pas d’astreinte.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-ça-change-quand-on-commande-un-logiciel&quot;&gt;Ce que ça change quand on commande un logiciel&lt;/h2&gt;

&lt;p&gt;Ran Craycraft, chez thoughtbot, &lt;a href=&quot;https://thoughtbot.com/blog/when-to-vibe-code-an-app-and-when-to-hire-someone&quot;&gt;pose bien la question&lt;/a&gt; : faire construire un prototype par une IA ne coûte presque plus rien, donc autant s’en servir pour vérifier qu’une idée mérite un vrai investissement. Il ajoute que le moment dangereux arrive quand le prototype marche et qu’on croit la production à portée de main. Un prototype sert à apprendre vite. Une application en production doit protéger des données, contrôler des accès et tenir quand quelque chose tombe.&lt;/p&gt;

&lt;p&gt;Le code était rarement le poste le plus cher d’un projet, seulement le plus visible. Quand vous payez un prestataire, vous payez surtout les décisions qu’il prend avec vous, ce qu’il vérifie avant de livrer, sécurité comprise, et le fait qu’il réponde de ce qui tourne chez vous. Un devis « trois fois plus rapide grâce à l’IA » suppose que le projet se résumait à écrire du code : demandez ce que deviennent les décisions, les vérifications, la sécurité et la production.&lt;/p&gt;

&lt;p&gt;Senko Rašić &lt;a href=&quot;https://blog.senko.net/code-was-never-the-hard-part-is-an-insult-to-all-programmers&quot;&gt;rappelle&lt;/a&gt; que dire « le code n’a jamais été la partie difficile » méprise un métier entier. Écrire du code demande toujours un savoir-faire. Son coût a baissé, celui du reste non.&lt;/p&gt;

&lt;p&gt;Si vous hésitez entre un prototype et un vrai projet, on peut regarder votre contexte ensemble.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opinion" />
    <category term="IA" />
    <category term="Opinion" />
    <category term="Méthode" />
    <category term="Dirigeants" />
    <summary type="html">L&#39;IA écrit une bonne part de mon code, mes journées ne sont pas plus courtes. Ce qui a accéléré, ce qui n&#39;a pas bougé, ce que ça change pour qui commande.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-10-22-ce-que-l-ia-n-a-pas-rendu-plus-rapide.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Le popover qui clignote au survol, ce petit bug qui trahit tout le reste</title>
    <link href="https://sxnlabs.com/web/2026/10/15/popover-qui-clignote-au-survol/" rel="alternate" type="text/html" title="Le popover qui clignote au survol, ce petit bug qui trahit tout le reste" />
    <published>2026-10-15T09:00:00+02:00</published>
    <updated>2026-10-15T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/web/2026/10/15/popover-qui-clignote-au-survol/</id>
    <content type="html" xml:base="https://sxnlabs.com/web/2026/10/15/popover-qui-clignote-au-survol/">&lt;p&gt;Sur une application métier que je développe, des cartes de réglages affichent chacune un petit badge « ? » qui ouvre une bulle d’aide au survol. Rien d’exotique, un pattern qu’on voit sur la moitié des interfaces web. Sauf que la bulle clignotait. On approchait la souris du badge, l’aide apparaissait, disparaissait, réapparaissait, dans un scintillement nerveux qui donnait l’impression que l’écran buggait. Et il buggait, un peu.&lt;/p&gt;

&lt;p&gt;Ce genre de détail ne casse aucune fonctionnalité. On peut le laisser traîner pendant des mois, personne n’ouvre de ticket pour ça. Mais c’est exactement le genre de scintillement qui, chez l’utilisateur, installe un doute diffus : « ce logiciel n’est pas fini ». La perception de qualité d’un produit métier se joue autant sur ces micro-détails que sur les grosses features. C’est la &lt;a href=&quot;https://fr.wikipedia.org/wiki/Hypoth%C3%A8se_de_la_vitre_bris%C3%A9e&quot;&gt;vitre brisée&lt;/a&gt; appliquée au logiciel : un défaut visible qu’on laisse en l’état signale que personne ne regarde le reste de près, et il finit par en autoriser d’autres.&lt;/p&gt;

&lt;h2 id=&quot;pourquoi-la-bulle-clignote&quot;&gt;Pourquoi la bulle clignote&lt;/h2&gt;

&lt;p&gt;Le réflexe naïf pour ce genre de bulle : on ouvre au &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt; du badge, on ferme au &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt;. Ça marche tant que le curseur reste sur le badge. Le problème surgit dès qu’il y a un espace, même d’un pixel, entre le badge et la bulle, ce qui est presque toujours le cas puisque la bulle s’affiche &lt;em&gt;à côté&lt;/em&gt; du badge, pas dessus.&lt;/p&gt;

&lt;p&gt;Quand la souris quitte le badge pour se diriger vers la bulle, elle traverse ce vide. Le navigateur émet un &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt; sur le badge → on ferme. La bulle disparaît sous le curseur → le badge se retrouve à nouveau sous la souris → &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt; → on rouvre. &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt; → on referme. À la fréquence de rafraîchissement de l’écran, ça donne un stroboscope. Le pointeur oscille sur une frontière instable, et chaque franchissement déclenche un événement contradictoire.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;960&quot; height=&quot;388&quot; src=&quot;/images/posts/popover-hover-intent/zone-sensible.fr.svg&quot; alt=&quot;À gauche, la zone sensible s&#39;arrête au badge : le curseur traverse deux pixels de vide, mouseleave ferme la bulle, le badge repasse sous le curseur et mouseenter la rouvre, en boucle. À droite, badge et bulle forment une seule zone et un délai de 120 ms rend le vide franchissable.&quot; /&gt;
  &lt;figcaption&gt;Deux pixels de vide suffisent à faire osciller le pointeur entre deux états contradictoires.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Le second problème apparaît quand on empile plusieurs cartes côte à côte : en balayant la grille avec la souris, on ouvrait trois bulles d’un coup, qui se chevauchaient. Deux bugs distincts, une seule cause de fond : on traite le survol comme un signal binaire instantané, alors que l’intention de l’utilisateur, elle, a une durée et un contexte.&lt;/p&gt;

&lt;h2 id=&quot;interpréter-une-intention-plutôt-que-des-événements&quot;&gt;Interpréter une intention plutôt que des événements&lt;/h2&gt;

&lt;p&gt;La bonne abstraction s’appelle &lt;em&gt;hover intent&lt;/em&gt;. On ne réagit pas à chaque &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt;/&lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt; isolé ; on interprète une intention : « l’utilisateur veut lire cette aide » (il s’attarde), « il a fini » (il s’éloigne pour de bon).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Un délai de grâce à la fermeture.&lt;/strong&gt; Au &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt;, on ne ferme pas tout de suite : on arme un timer de ~120 ms. Si le curseur arrive sur la bulle pendant ce délai, on annule le timer. Le petit vide entre badge et bulle devient franchissable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La bulle fait partie de la zone sensible.&lt;/strong&gt; Le &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt; sur la bulle elle-même annule toute fermeture en cours ; son &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt; réarme le timer. Badge et bulle forment une seule zone logique, même s’ils sont visuellement séparés.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Une seule bulle ouverte à la fois.&lt;/strong&gt; Avant d’ouvrir, on ferme celle qui traîne. Un simple registre partagé au niveau du module suffit, pas besoin d’un state manager pour ça.&lt;/p&gt;

&lt;p&gt;Voici le tout dans un contrôleur Stimulus, sans dépendance :&lt;/p&gt;

&lt;div class=&quot;language-javascript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Controller&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@hotwired/stimulus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Registre partagé : une seule bulle ouverte à la fois.&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;openController&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Controller&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;targets&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

  &lt;span class=&quot;nf&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Sur écran tactile, pas de survol : on repassera au tap.&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;hoverable&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;matchMedia&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;(hover: hover)&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;matches&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;closeTimer&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nf&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;hoverable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;clearTimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;closeTimer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;openController&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;openController&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;openController&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;hide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;contentTarget&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;hidden&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;openController&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nf&quot;&gt;scheduleClose&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;hoverable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;closeTimer&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;setTimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;hide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;120&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nf&quot;&gt;cancelClose&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;clearTimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;closeTimer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nf&quot;&gt;hide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;contentTarget&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;hidden&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;openController&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;openController&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Et le HTML, où le badge &lt;em&gt;et&lt;/em&gt; la bulle sont tous deux câblés sur les mêmes actions :&lt;/p&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-controller=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;popover&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;button&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;type=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;button&quot;&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;data-action=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;mouseenter-&amp;gt;popover#open mouseleave-&amp;gt;popover#scheduleClose
                       focus-&amp;gt;popover#open blur-&amp;gt;popover#hide&quot;&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;aria-describedby=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;help-&lt;/span&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;card&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;?&lt;span class=&quot;nt&quot;&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

  &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;help-&lt;/span&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;card&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;role=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;tooltip&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;hidden&lt;/span&gt;
       &lt;span class=&quot;na&quot;&gt;data-popover-target=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;content&quot;&lt;/span&gt;
       &lt;span class=&quot;na&quot;&gt;data-action=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;mouseenter-&amp;gt;popover#cancelClose mouseleave-&amp;gt;popover#scheduleClose&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;card&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;help_text&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Le &lt;code class=&quot;highlighter-rouge&quot;&gt;cancelClose&lt;/code&gt; sur la bulle est la pièce maîtresse : c’est lui qui rend le vide entre badge et bulle franchissable. Retirez-le et le délai de grâce ne sert plus qu’à retarder le clignotement.&lt;/p&gt;

&lt;h2 id=&quot;deux-détails-qui-font-la-différence&quot;&gt;Deux détails qui font la différence&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Le tactile n’a pas de survol.&lt;/strong&gt; Sur téléphone ou tablette, &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt; se déclenche au premier tap et ne repart jamais tant qu’on ne touche pas ailleurs, et la bulle reste collée. Le garde &lt;code class=&quot;highlighter-rouge&quot;&gt;matchMedia(&quot;(hover: hover)&quot;)&lt;/code&gt; neutralise la logique de survol sur ces appareils ; là, un simple tap qui bascule l’affichage est plus honnête. Tester une interaction de survol &lt;em&gt;uniquement&lt;/em&gt; à la souris est un piège classique : ça marche parfaitement sur le poste du développeur et casse sur le terrain, là où beaucoup d’utilisateurs métier sont sur tablette ou sur téléphone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le clavier et les lecteurs d’écran.&lt;/strong&gt; J’ai câblé &lt;code class=&quot;highlighter-rouge&quot;&gt;focus&lt;/code&gt;/&lt;code class=&quot;highlighter-rouge&quot;&gt;blur&lt;/code&gt; en plus du survol, et lié le badge à sa bulle via &lt;code class=&quot;highlighter-rouge&quot;&gt;aria-describedby&lt;/code&gt;. Une aide contextuelle qui n’existe qu’à la souris n’existe pas pour qui navigue au clavier. C’est peu de code, et ça évite d’exclure une partie des utilisateurs sans même s’en rendre compte.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-je-retiens&quot;&gt;Ce que je retiens&lt;/h2&gt;

&lt;p&gt;Le correctif tient en trois idées (délai de grâce, zone sensible étendue à la bulle, une seule ouverte) et une trentaine de lignes. Aucune librairie, aucun composant lourd. La difficulté était de &lt;em&gt;voir&lt;/em&gt; le problème et de refuser de le laisser passer.&lt;/p&gt;

&lt;p&gt;Un logiciel métier se juge à l’usage, dans les mille micro-frictions du quotidien plutôt que pendant la démo. Absorber ces frictions côté prestataire relève du travail invisible : ça ne remonte dans aucun cahier des charges, et l’utilisateur ne le remarque qu’en creux, le jour où l’outil cesse de se mettre en travers.&lt;/p&gt;

&lt;p&gt;Si vous avez un outil interne qui « marche » mais que vos équipes trouvent vaguement pénible sans savoir dire pourquoi, c’est souvent une accumulation de ces détails-là. On peut regarder ensemble lesquels valent vraiment la peine d’être corrigés.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="web" />
    <category term="Stimulus" />
    <category term="Hotwire" />
    <category term="UX" />
    <category term="JavaScript" />
    <category term="CSS" />
    <summary type="html">Une bulle d&#39;aide qui scintille au survol, la cause exacte, et le correctif en trente lignes de Stimulus, tactile et clavier compris.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-10-15-popover-qui-clignote-au-survol.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Un carnet manuscrit hors-ligne dans le navigateur, sans app native</title>
    <link href="https://sxnlabs.com/ruby/2026/10/08/carnet-manuscrit-hors-ligne-tablette/" rel="alternate" type="text/html" title="Un carnet manuscrit hors-ligne dans le navigateur, sans app native" />
    <published>2026-10-08T09:00:00+02:00</published>
    <updated>2026-10-08T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/ruby/2026/10/08/carnet-manuscrit-hors-ligne-tablette/</id>
    <content type="html" xml:base="https://sxnlabs.com/ruby/2026/10/08/carnet-manuscrit-hors-ligne-tablette/">&lt;p&gt;Une application métier que je développe propose un carnet de notes manuscrites sur tablette. Ses utilisateurs travaillent sur le terrain, souvent là où le réseau est mauvais ou absent, et ils écrivent au stylet comme sur un bloc papier. Deux exigences qui réclament d’ordinaire une application native.&lt;/p&gt;

&lt;p&gt;Je ne suis pas parti sur du natif. Une PWA (une page web installable, servie par un Rails classique) fait le travail, à condition de respecter trois réalités de l’appareil : il n’y a pas toujours de réseau, le stylet est un vrai stylet, et il crache des données beaucoup plus vite qu’on ne l’imagine. Chacune cache un piège que les tutoriels ne mentionnent pas.&lt;/p&gt;

&lt;h2 id=&quot;la-mise-à-jour-qui-efface-les-notes-hors-ligne&quot;&gt;La mise à jour qui efface les notes hors ligne&lt;/h2&gt;

&lt;p&gt;Le offline-first repose sur un service worker qui met les pages en cache et les ressert quand le réseau tombe. Le schéma est connu. On nomme un cache, on y stocke les pages consultées, et au déploiement suivant on renomme le cache pour invalider l’ancien contenu.&lt;/p&gt;

&lt;p&gt;C’est exactement ce renommage qui crée une panne silencieuse. Quand on passe &lt;code class=&quot;highlighter-rouge&quot;&gt;carnet-v2&lt;/code&gt; à &lt;code class=&quot;highlighter-rouge&quot;&gt;v3&lt;/code&gt;, l’événement &lt;code class=&quot;highlighter-rouge&quot;&gt;activate&lt;/code&gt; du nouveau service worker supprime l’ancien cache. Tout son contenu part avec, y compris les notes que l’utilisateur avait déjà consultées hors ligne. Résultat, quelqu’un installe la mise à jour le matin au dépôt, part sur le terrain, perd le réseau, ouvre ses notes… et tombe sur la page « pas de connexion ». Ses données existaient, on venait juste de les jeter.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;960&quot; height=&quot;366&quot; src=&quot;/images/posts/carnet-offline/cache-purge.fr.svg&quot; alt=&quot;Deux déroulés du même déploiement. Sans migration, activate supprime l&#39;ancien cache et l&#39;utilisateur parti sur le terrain tombe sur la page « pas de connexion ». Avec migration, install recopie les entrées dans le nouveau cache et les notes restent lisibles hors réseau.&quot; /&gt;
  &lt;figcaption&gt;Le renommage du cache est ce qui invalide l&#39;ancien contenu, et c&#39;est aussi ce qui jette les notes déjà consultées.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Le correctif est de &lt;strong&gt;migrer les entrées&lt;/strong&gt; de l’ancien cache vers le nouveau avant que &lt;code class=&quot;highlighter-rouge&quot;&gt;activate&lt;/code&gt; ne fasse le ménage :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;install&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;waitUntil&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;async &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cache&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;caches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;CACHE_NAME&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Reprendre les entrées des caches précédents avant qu&#39;`activate`&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ne les purge, sinon renommer CACHE_NAME efface les pages /admin/notes&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// déjà en cache, et l&#39;utilisateur qui passe hors ligne juste après la&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// mise à jour atterrit sur une page d&#39;erreur sans issue.&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;of&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;caches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;keys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;CACHE_NAME&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;continue&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;previous&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;caches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;of&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;previous&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;keys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;match&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;continue&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;previous&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;match&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;put&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;OFFLINE_URL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// la page offline, elle, est toujours rafraîchie&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;})())&lt;/span&gt;
  &lt;span class=&quot;nb&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;skipWaiting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Dans la foulée, la page offline cesse d’être un cul-de-sac : si les notes sont en cache, un script la fait rebondir dessus au lieu d’annoncer un échec.&lt;/p&gt;

&lt;h2 id=&quot;le-stylet-qui-écrit--trop-épais-&quot;&gt;Le stylet qui écrit « trop épais »&lt;/h2&gt;

&lt;p&gt;Pour le rendu du trait, j’utilise &lt;a href=&quot;https://github.com/steveruizok/perfect-freehand&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;perfect-freehand&lt;/code&gt;&lt;/a&gt;, qui transforme une suite de points en un contour lissé à largeur variable. Par défaut, la librairie &lt;em&gt;simule&lt;/em&gt; la pression à partir de la vitesse du geste : on va vite, le trait s’affine ; on va lentement, il s’épaissit.&lt;/p&gt;

&lt;p&gt;Excellent pour un dessin. Catastrophique pour de l’écriture manuscrite. Quand on écrit, on va lentement et avec application, exactement le régime où la simulation gonfle le trait. Résultat, des lettres pâteuses qui ne ressemblent pas à ce que la main fait sur l’écran.&lt;/p&gt;

&lt;p&gt;La tablette a un vrai stylet, qui rapporte une vraie pression via &lt;code class=&quot;highlighter-rouge&quot;&gt;PointerEvent.pressure&lt;/code&gt;. La bonne réponse est donc d’éteindre la simulation et de n’utiliser que la pression matérielle quand elle existe :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;FREEHAND_PEN_OPTS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;freeze&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;smoothing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.45&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;streamline&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// La largeur vient de la pression réelle du stylet, jamais de la vitesse :&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// la simulation épaississait le trait aux vitesses lentes de l&#39;écriture.&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;simulatePressure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// Peu de modulation : à forte pression, le trait redevenait pâteux.&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;thinning&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// Sans ça, le lissage rattrape aussi le dernier point. Voir plus bas.&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;last&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Avec une subtilité : &lt;code class=&quot;highlighter-rouge&quot;&gt;PointerEvent.pressure&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;0.5&lt;/code&gt; quand l’appareil ne rapporte rien, souris et doigt compris. On détecte donc une fois par tracé si une valeur sort de ce &lt;code class=&quot;highlighter-rouge&quot;&gt;0.5&lt;/code&gt; constant, et la largeur ne varie que dans ce cas.&lt;/p&gt;

&lt;h2 id=&quot;le-trait-qui-ne-colle-pas-à-la-pointe&quot;&gt;Le trait qui ne colle pas à la pointe&lt;/h2&gt;

&lt;p&gt;Le symptôme était difficile à qualifier. Pas de lenteur visible, pas d’à-coup franc, seulement l’impression que le trait suivait le stylet au lieu de sortir de sa pointe. J’ai donc mesuré avant de toucher au code, avec deux scripts qui rejouent des tracés réels, l’un pour l’écart géométrique entre la pointe et le trait, l’autre pour le coût du pipeline dans Chromium. Deux causes sont sorties.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La pointe était filtrée.&lt;/strong&gt; L’option &lt;code class=&quot;highlighter-rouge&quot;&gt;streamline&lt;/code&gt; lisse chaque point vers le précédent, et sans &lt;code class=&quot;highlighter-rouge&quot;&gt;last: true&lt;/code&gt; elle lisse aussi le dernier, celui qui est censé se trouver sous le stylet. Sur 67 tracés réels, 3497 échantillons, la pointe rendue traînait de 6,1 px en moyenne et jusqu’à 75,8 px sur les gestes rapides. Un booléen ramène l’écart à zéro et ne laisse le lissage qu’au sillage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le thread gelait au pire moment.&lt;/strong&gt; Chaque &lt;code class=&quot;highlighter-rouge&quot;&gt;pointerup&lt;/code&gt; programmait un instantané bitmap de la note en &lt;code class=&quot;highlighter-rouge&quot;&gt;requestIdleCallback&lt;/code&gt;. Comme la lettre suivante arrive une centaine de millisecondes plus tard, le rappel tombait en plein tracé : recalcul de tous les contours, remplissage d’un canvas 2D et encodage WebP synchrone. Le trait se figeait de 150 à 950 ms avant de rattraper le stylet d’un coup.&lt;/p&gt;

&lt;p&gt;Le correctif ne doit rien à un algorithme. L’instantané attend une pause d’une seconde et demie, ne part jamais pendant un geste et encode hors du thread principal ; le trait en cours a quitté le SVG des tracés persistés, où sa réécriture à chaque image faisait recalculer toute la scène, pour un canvas dédié en &lt;code class=&quot;highlighter-rouge&quot;&gt;desynchronized: true&lt;/code&gt;. La pire image pendant l’écriture est passée de 199 à 34 ms sur une note légère, et de 935 à 21 ms sur une note de 370 tracés.&lt;/p&gt;

&lt;p&gt;Reste le débit brut. À 500 Hz, la plupart des échantillons tombent à moins d’un pixel du précédent, sans ajouter de détail visible. Un filtrage par distance en espace écran les écarte.&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;dx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ev&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;clientX&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;lastSampleClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;x&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;dy&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ev&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;clientY&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;lastSampleClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;y&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;dx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;dx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;dy&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;dy&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;MIN_SAMPLE_DIST_SQ&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;continue&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// &amp;lt; 1,2 px : on jette&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;lastSampleClient&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ev&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;clientX&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ev&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;clientY&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;La distance se mesure en pixels écran et non en coordonnées document, pour que la densité tienne à tous les zooms, et on compare son carré pour éviter une racine par point. Le filtre dédoublonne au passage les &lt;code class=&quot;highlighter-rouge&quot;&gt;pointerrawupdate&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;pointermove&lt;/code&gt; qui décrivent le même point.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;960&quot; height=&quot;348&quot; src=&quot;/images/posts/carnet-offline/decimation.fr.svg&quot; alt=&quot;Le même tracé présenté deux fois. À gauche, les 96 échantillons bruts du stylet se chevauchent presque tous à moins de 1,2 pixel. À droite, 17 points sont conservés et le contour rendu est identique.&quot; /&gt;
  &lt;figcaption&gt;Les échantillons jetés ne portaient aucun détail visible, seulement du calcul par image.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;getCoalescedEvents()&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;getPredictedEvents()&lt;/code&gt; étaient en place bien avant ce diagnostic et n’y étaient pour rien. La liste des bonnes pratiques était cochée, et le trait décrochait quand même.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-jen-retiens&quot;&gt;Ce que j’en retiens&lt;/h2&gt;

&lt;p&gt;Aucun de ces correctifs n’est sophistiqué : une boucle, deux booléens, une comparaison de distances. Ce qui coûte, c’est d’identifier le bon problème. Le cache qui s’efface demande une vraie mauvaise connexion, le trait pâteux un vrai stylet en main, la pointe qui décroche un instrument de mesure. Rien de tout ça ne se manifeste sur un MacBook en Wi-Fi pendant le développement.&lt;/p&gt;

&lt;p&gt;Le navigateur est largement capable d’une expérience proche du natif, sans le coût d’une application à maintenir, à signer et à faire passer par un store. Ce qu’il demande en échange, c’est un banc de test qui ressemble au terrain : une vraie tablette, un vrai stylet, et le mode avion.&lt;/p&gt;

&lt;h2 id=&quot;si-ce-sujet-vous-parle&quot;&gt;Si ce sujet vous parle&lt;/h2&gt;

&lt;p&gt;Carnet hors ligne, synchronisation qui survit à une connexion pourrie, application terrain qui doit tenir sur un iPad qui s’endort : c’est exactement le genre de friction que je débloque chez &lt;a href=&quot;/contact/?ref=carnet-offline&quot;&gt;SXN Labs&lt;/a&gt;. Si vous avez un usage métier coincé entre « il faudrait une app native » et « le web n’y arrivera pas », on peut regarder ce que ça demande vraiment.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="ruby" />
    <category term="PWA" />
    <category term="Service Worker" />
    <category term="Stimulus" />
    <category term="Offline-first" />
    <category term="JavaScript" />
    <summary type="html">Écriture au stylet et fonctionnement hors ligne sur tablette, sans app native. Trois pièges que les tutoriels PWA ne mentionnent pas, et leurs correctifs.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-10-08-carnet-manuscrit-hors-ligne-tablette.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Connecter un logiciel Rails à l&#39;ordonnance numérique via Ordoclic</title>
    <link href="https://sxnlabs.com/e-sante/2026/10/01/e-prescription-via-ordoclic-rails/" rel="alternate" type="text/html" title="Connecter un logiciel Rails à l&#39;ordonnance numérique via Ordoclic" />
    <published>2026-10-01T09:00:00+02:00</published>
    <updated>2026-10-01T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/e-sante/2026/10/01/e-prescription-via-ordoclic-rails/</id>
    <content type="html" xml:base="https://sxnlabs.com/e-sante/2026/10/01/e-prescription-via-ordoclic-rails/">&lt;p&gt;ORIGAMI, le logiciel métier que je développe et maintiens en production pour la régulation médicale, propose désormais l’ordonnance numérique à ses prescripteurs. Le branchement n’a pas été fait en direct contre les téléservices de l’Assurance Maladie, mais via &lt;a href=&quot;https://www.ordoclic.fr/&quot;&gt;Ordoclic&lt;/a&gt;, qui expose ses services e-santé par API à des éditeurs intégrateurs. La certification CNDA Ordonnance numérique, elle, est portée par ORIGAMI : nous avons passé les pré-séries en notre nom, avec Ordoclic comme socle technique.&lt;/p&gt;

&lt;p&gt;Je documente ce chemin parce qu’il est mal connu, et que des éditeurs de taille modeste y renoncent en croyant devoir tout absorber.&lt;/p&gt;

&lt;h2 id=&quot;un-logiciel-métier-qui-doit-prescrire&quot;&gt;Un logiciel métier qui doit prescrire&lt;/h2&gt;

&lt;p&gt;ORIGAMI est une application Rails utilisée par des praticiens en activité. À un moment, la prescription électronique cesse d’être une option : le papier devient un irritant pour le médecin, qui veut transmettre directement au pharmacien, et pour le patient, qui ne veut pas balader une feuille. Il faut générer des prescriptions structurées, signées, déposées dans la base sécurisée hébergée par la CNAM, et interrogeables par le pharmacien à partir d’un identifiant.&lt;/p&gt;

&lt;h2 id=&quot;deux-chemins-deux-surfaces-réglementaires&quot;&gt;Deux chemins, deux surfaces réglementaires&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Le chemin direct.&lt;/strong&gt; L’éditeur s’enregistre auprès de l’ANS, monte sa chaîne PKI, gère ses certificats serveur et logiciel, intègre Pro Santé Connect ou les cartes CPS pour authentifier les professionnels, dialogue avec le téléservice de l’ordonnance numérique, &lt;strong&gt;développe et fait certifier son propre LAP&lt;/strong&gt; (Logiciel d’Aide à la Prescription) auprès de la HAS, avec base médicamenteuse agréée, contrôles d’interactions, contre-indications et posologies, puis passe le dossier CNDA pour son propre compte. C’est la voie des gros éditeurs historiques, et elle a sa cohérence : la chaîne est contrôlée de bout en bout, sans dépendance. Son coût d’entrée et de maintenance déforme le produit d’une structure d’un ou deux développeurs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le chemin de l’API intégrateur.&lt;/strong&gt; L’éditeur s’appuie sur un partenaire déjà raccordé aux téléservices et déjà certifié LAP, qui factorise la plomberie réglementaire derrière une API. Le logiciel métier consomme les services et passe sa propre certification CNDA en s’appuyant sur ce socle. Pour le médecin, l’expérience est celle d’un éditeur en raccordement direct : il prescrit, ça part, le pharmacien interroge la base.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;960&quot; height=&quot;446&quot; src=&quot;/images/posts/ordonnance-numerique/deux-chemins.fr.svg&quot; alt=&quot;Deux colonnes comparant le raccordement direct, où l&#39;éditeur porte l&#39;enregistrement ANS, la chaîne PKI, Pro Santé Connect, le LAP certifié HAS et le dossier CNDA, et le chemin de l&#39;API intégrateur, où le partenaire porte les quatre premiers blocs et l&#39;éditeur garde sa certification CNDA en propre.&quot; /&gt;
  &lt;figcaption&gt;Le choix se joue sur la surface réglementaire portée en interne. La certification CNDA reste dans les deux cas au nom de l&#39;éditeur.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;ce-quordoclic-prend-en-charge&quot;&gt;Ce qu’Ordoclic prend en charge&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Le raccordement aux téléservices CNAM et à l’écosystème ANS, avec les certificats serveur et la chaîne IGC-Santé associés.&lt;/li&gt;
  &lt;li&gt;L’authentification du professionnel via Pro Santé Connect, e-CPS et lecture de carte CPS, sans manipuler ces flux bas niveau côté éditeur.&lt;/li&gt;
  &lt;li&gt;La signature de la prescription et son dépôt dans la base sécurisée, au format attendu par les pharmaciens, avec QR code unique.&lt;/li&gt;
  &lt;li&gt;L’interrogation côté dispensateur à partir de l’identifiant d’ordonnance.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Le LAP lui-même&lt;/strong&gt; : moteur certifié HAS V2 en ambulatoire, marqué CE comme dispositif médical, agréé CNDA pour l’ordonnance numérique, adossé à une base médicamenteuse actualisée (Vidal) avec alertes d’interactions, contre-indications et posologies. C’est le morceau le plus lourd à porter seul : référentiel HAS exigeant, base agréée à intégrer, revalidation à chaque évolution.&lt;/li&gt;
  &lt;li&gt;L’accompagnement aux pré-séries CNDA, étape obligatoire pour que l’éditeur intégrateur obtienne sa certification.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;ce-qui-reste-à-léditeur&quot;&gt;Ce qui reste à l’éditeur&lt;/h2&gt;

&lt;p&gt;La prescription n’a pas lieu dans ORIGAMI. Le médecin clique sur un bouton qui ouvre le LAP d’Ordoclic avec le contexte déjà posé, et c’est là qu’il prescrit, devant les alertes d’interaction et de contre-indication de la base médicamenteuse. Quand il a terminé, ORIGAMI reçoit par webhook l’ordonnance sécurisée au format PDF, la rattache au dossier et la transmet au patient par un canal sécurisé.&lt;/p&gt;

&lt;p&gt;Ce découpage pousse le travail de l’éditeur aux deux extrémités. En amont, transmettre le bon contexte : quel praticien agit, sous quel numéro RPPS, dans quel contexte d’exercice, pour quel patient. Cette cinématique se cale à la configuration du compte du médecin et doit rester à jour à chaque prescription. En aval, recevoir les webhooks : un endpoint qui répond vite, qui supporte d’être rejoué sans créer de doublon, et qui trace ce qu’il a rattaché.&lt;/p&gt;

&lt;p&gt;La responsabilité clinique du prescripteur, elle, ne se délègue nulle part. Le LAP affiche les alertes, le médecin tranche, et l’ordonnance qui revient dans le dossier porte sa décision.&lt;/p&gt;

&lt;h2 id=&quot;quand-le-partenaire-est-indisponible&quot;&gt;Quand le partenaire est indisponible&lt;/h2&gt;

&lt;p&gt;C’est le coût de ce chemin : si Ordoclic ne répond pas, personne ne prescrit par voie électronique. Aucun mode dégradé ne produira l’ordonnance en différé, puisque la signature et le dépôt dans la base CNAM passent par le partenaire. Le repli est l’ordonnance papier.&lt;/p&gt;

&lt;p&gt;Autant le dire aux prescripteurs avant la mise en service. &lt;a href=&quot;/ruby/2026/06/04/racine-igc-sante-tls-insi/&quot;&gt;INSi&lt;/a&gt; encaissait mieux la panne : l’acte continuait, seule la vérification d’identité restait en suspens. Ici la fonctionnalité s’arrête net, et la disponibilité annoncée aux médecins est celle du partenaire, pas la vôtre.&lt;/p&gt;

&lt;h2 id=&quot;le-branchement-côté-rails&quot;&gt;Le branchement côté Rails&lt;/h2&gt;

&lt;p&gt;Le code n’a rien d’exotique : des appels HTTP authentifiés contre l’API intégrateur depuis une couche service dédiée, et un endpoint qui reçoit les webhooks. Les patterns du reste du logiciel s’appliquent sans changement.&lt;/p&gt;

&lt;p&gt;Le webhook est la pièce qui demande le plus de soin. Il arrive après coup, sur un serveur qui n’a pas suivi la session de prescription : il faut retrouver le dossier concerné, vérifier que l’ordonnance n’a pas déjà été rattachée, et répondre assez vite pour ne pas provoquer un rejeu inutile.&lt;/p&gt;

&lt;p&gt;Une ordonnance numérique au sens du téléservice est un jeu de données structurées, signées et déposées dans la base CNAM, que le pharmacien interroge par identifiant. Le PDF que le patient reçoit en est la copie, et aucun pharmacien ne le lit.&lt;/p&gt;

&lt;p&gt;Transmettre cette copie reste à la charge de l’éditeur. ORIGAMI envoie un email portant un lien à jeton, et le patient confirme son identité avec ses initiales avant que le document s’ouvre. Les laboratoires d’analyses et Doctolib ont déjà installé ce geste chez les patients, ce qui épargne un mode d’emploi.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;960&quot; height=&quot;364&quot; src=&quot;/images/posts/ordonnance-numerique/parcours-prescription.fr.svg&quot; alt=&quot;Le parcours d&#39;une prescription : depuis ORIGAMI un bouton ouvre le LAP d&#39;Ordoclic avec le contexte, le médecin y prescrit, l&#39;ordonnance est signée puis déposée dans la base CNAM et le pharmacien l&#39;interroge par l&#39;identifiant du QR code. Une flèche de retour ramène l&#39;ordonnance dans ORIGAMI par webhook. En dessous, l&#39;indisponibilité d&#39;Ordoclic qui impose le repli papier, et le rappel que le PDF va au patient tandis que le pharmacien interroge la base.&quot; /&gt;
  &lt;figcaption&gt;L&#39;éditeur tient les deux extrémités, le bouton qui ouvre le LAP et le webhook qui ramène l&#39;ordonnance. L&#39;acte lui-même se passe ailleurs.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;la-certification-cnda-portée-par-léditeur&quot;&gt;La certification CNDA, portée par l’éditeur&lt;/h2&gt;

&lt;p&gt;La certification CNDA Ordonnance numérique obtenue dans ce cadre appartient à l’éditeur intégrateur, ORIGAMI en l’occurrence, pour une version donnée du logiciel. Elle s’obtient en passant les pré-séries CNDA en s’appuyant sur le LAP et le moteur d’Ordoclic comme socle conforme, et elle autorise la diffusion de cette version auprès des professionnels de santé.&lt;/p&gt;

&lt;p&gt;Ce n’est donc ni un agrément obtenu seul en raccordement direct, ni un agrément hérité passivement du partenaire. Elle est tracée, attachée à une version précise, et rejouée à chaque évolution significative.&lt;/p&gt;

&lt;h2 id=&quot;le-pattern-pour-dautres-éditeurs-métier&quot;&gt;Le pattern, pour d’autres éditeurs métier&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;S’appuyer sur un partenaire intégrateur plutôt que viser un raccordement direct et un LAP maison, sauf à vouloir devenir éditeur PKI et LAP à plein temps.&lt;/li&gt;
  &lt;li&gt;Garder l’éditeur responsable de ce qui entoure l’acte (contexte transmis au LAP, réception des webhooks, rattachement au dossier, transmission sécurisée du PDF au patient, traçabilité) et déléguer la plomberie réglementaire, la base médicamenteuse et la prescription elle-même.&lt;/li&gt;
  &lt;li&gt;Prévoir le repli papier et l’annoncer aux prescripteurs, parce que la disponibilité de la fonctionnalité devient celle du partenaire.&lt;/li&gt;
  &lt;li&gt;Traiter la certification CNDA comme un état attaché à une version, rejoué à chaque évolution significative.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;si-vous-avez-ça-dans-votre-boîte&quot;&gt;Si vous avez ça dans votre boîte&lt;/h2&gt;

&lt;p&gt;Si vous éditez un logiciel métier de santé et que l’ordonnance numérique est sur votre roadmap, la voie de l’API intégrateur existe et fonctionne. C’est le genre de sujet que je traite chez &lt;a href=&quot;/contact/?ref=ordoclic-eprescription&quot;&gt;SXN Labs&lt;/a&gt; : cadrer le besoin, intégrer le partenaire dans une application Rails, accompagner les pré-séries CNDA, et laisser derrière un système exploitable et agréable à maintenir.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="e-sante" />
    <category term="e-santé" />
    <category term="Ordonnance numérique" />
    <category term="Ordoclic" />
    <category term="CNDA" />
    <category term="Rails" />
    <category term="Retour d&#39;expérience" />
    <summary type="html">ORIGAMI propose l&#39;ordonnance numérique sans raccordement direct à l&#39;ANS ni LAP maison. Ce que le partenaire absorbe, ce qui reste à l&#39;éditeur.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-10-01-e-prescription-via-ordoclic-rails.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Le code était juste, le package livré ne l&#39;était pas</title>
    <link href="https://sxnlabs.com/opensource/2026/09/24/verifier-le-package-livre-pas-le-code/" rel="alternate" type="text/html" title="Le code était juste, le package livré ne l&#39;était pas" />
    <published>2026-09-24T09:00:00+02:00</published>
    <updated>2026-09-24T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/opensource/2026/09/24/verifier-le-package-livre-pas-le-code/</id>
    <content type="html" xml:base="https://sxnlabs.com/opensource/2026/09/24/verifier-le-package-livre-pas-le-code/">&lt;p&gt;Fin août, j’ai publié la version 0.9.3 d’&lt;a href=&quot;/gems/einvoicing/&quot;&gt;einvoicing&lt;/a&gt;, ma gem de facturation électronique. Elle n’ajoute ni ne corrige aucune fonctionnalité. La 0.9.2 ne se chargeait pas dans un projet qui n’avait pas déjà &lt;code class=&quot;highlighter-rouge&quot;&gt;bigdecimal&lt;/code&gt;, alors que ses 317 tests passaient chez moi.&lt;/p&gt;

&lt;h2 id=&quot;chez-moi-ça-marche&quot;&gt;Chez moi, ça marche&lt;/h2&gt;

&lt;p&gt;La gem utilise &lt;code class=&quot;highlighter-rouge&quot;&gt;bigdecimal&lt;/code&gt; pour les montants et &lt;code class=&quot;highlighter-rouge&quot;&gt;rexml&lt;/code&gt; pour lire le XML. Ruby les installait autrefois avec lui, il faut maintenant les déclarer comme dépendances, et je ne l’avais pas fait. Sur mon poste, d’autres gems les installaient déjà. Ailleurs, &lt;code class=&quot;highlighter-rouge&quot;&gt;require &quot;einvoicing&quot;&lt;/code&gt; échouait immédiatement, et la validation Peppol dès qu’elle lisait du XML.&lt;/p&gt;

&lt;p&gt;Les tests tournent dans le repo. Le package publié ne contient que les fichiers listés dans le gemspec et les dépendances déclarées, et il s’installe sur un Ruby que je ne contrôle pas. Aucun test ne regardait ce package.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;1000&quot; height=&quot;470&quot; src=&quot;/images/posts/package-livre/depot-contre-package.fr.svg&quot; alt=&quot;Deux blocs reliés par la commande gem build. À gauche, le repo : les fichiers, les gems déjà installées sur le poste, un Gemfile.lock résolu en Ruby 4.0, vérifié par les 317 tests. À droite, le package : seulement les fichiers listés, seulement les dépendances déclarées, installé sur le Ruby de l&#39;utilisateur dès 3.2, vérifié par le nouveau contrôle. En dessous, ce qui peut se perdre au passage : une dépendance, un fichier, une version compatible 3.2.&quot; /&gt;
  &lt;figcaption&gt;Les tests vérifient le repo, l&#39;utilisateur installe le package.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;trois-écarts-entre-le-repo-et-le-package&quot;&gt;Trois écarts entre le repo et le package&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Une dépendance présente sur le poste, absente chez l’utilisateur. C’est l’écart qui a été publié.&lt;/li&gt;
  &lt;li&gt;Un fichier lu à l’exécution mais absent du gemspec. La gem embarque un profil sRGB dans chaque PDF/A-3 : retiré de la liste, tous les tests restent verts et la gem publiée ne produit plus de facture conforme. Je l’ai provoqué exprès, et aucun contrôle ne le voyait.&lt;/li&gt;
  &lt;li&gt;Un lockfile résolu sur Ruby 4.0, qui retient des versions exigeant Ruby 3.3 alors que la gem promet de tourner sur 3.2.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;les-contrôles-ajoutés&quot;&gt;Les contrôles ajoutés&lt;/h2&gt;

&lt;p&gt;Le premier construit la gem comme pour une publication, l’installe dans un environnement vide et la charge. Il compare ensuite, fichier par fichier, ce que le repo utilise à l’exécution et ce que le package contient. Une dépendance non déclarée fait échouer le chargement, un fichier manquant est identifié.&lt;/p&gt;

&lt;p&gt;Le second parcourt le lockfile et vérifie que chaque version accepte Ruby 3.2. Une gem qu’il n’a pas pu vérifier, par exemple quand RubyGems limite les requêtes, compte comme un échec.&lt;/p&gt;

&lt;p&gt;Ces contrôles avaient eux-mêmes des trous, signalés en relecture. Le premier chargeait le package sans regarder son contenu, si bien que retirer un fichier de traduction laissait tout au vert. Corrigé, il vérifiait qu’un glob du gemspec trouvait quelque chose au lieu de vérifier chaque fichier attendu. J’ai reproduit chaque trou en cassant le package exprès avant de le corriger.&lt;/p&gt;

&lt;h2 id=&quot;au-delà-de-ruby&quot;&gt;Au-delà de Ruby&lt;/h2&gt;

&lt;p&gt;Le même écart existe partout où l’on teste une chose et en livre une autre : un installeur essayé sur la machine qui l’a compilé, une image Docker construite avec un cache local, une application mobile validée sur le téléphone du développeur.&lt;/p&gt;

&lt;p&gt;Si vous faites développer un logiciel, demandez sur quoi tourne la dernière vérification avant livraison. Si elle tourne sur la machine du prestataire plutôt que sur ce qui vous est livré, elle ne couvre pas ce que vous recevez.&lt;/p&gt;

&lt;p&gt;La 0.9.3 et la 0.4.0 d’&lt;a href=&quot;https://github.com/sxnlabs/einvoicing-connect&quot;&gt;einvoicing-connect&lt;/a&gt; sont en ligne depuis le 31 août. Leur intégration continue lance désormais les tests sur Ruby 3.2 et 4.0, l’installation du package dans un environnement vide sur ces deux versions et la vérification du lockfile.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opensource" />
    <category term="Open source" />
    <category term="RubyGems" />
    <category term="E-invoicing" />
    <category term="Livraison" />
    <category term="CI" />
    <summary type="html">Une gem dont les 317 tests passaient et qui ne se chargeait pas sans deux dépendances non déclarées. Les contrôles qui vérifient le package livré.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-09-24-verifier-le-package-livre-pas-le-code.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">La TVA n&#39;est pas un taux, c&#39;est un régime</title>
    <link href="https://sxnlabs.com/opensource/2026/09/03/tva-regime-pas-taux-facture-electronique/" rel="alternate" type="text/html" title="La TVA n&#39;est pas un taux, c&#39;est un régime" />
    <published>2026-09-03T09:00:00+02:00</published>
    <updated>2026-09-03T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/opensource/2026/09/03/tva-regime-pas-taux-facture-electronique/</id>
    <content type="html" xml:base="https://sxnlabs.com/opensource/2026/09/03/tva-regime-pas-taux-facture-electronique/">&lt;p&gt;Sur un PDF, la TVA tient dans deux colonnes : un taux et un montant. Quand le taux est à zéro, on écrit la raison en bas de page, en français, et la personne qui reçoit la facture comprend. En facture électronique structurée, cette ligne de texte n’existe pas. Il faut déclarer un code de régime, et si ce régime n’appelle pas de TVA, un motif lui aussi codé. Sans ça, le flux est rejeté à l’entrée.&lt;/p&gt;

&lt;p&gt;Je maintiens &lt;code class=&quot;highlighter-rouge&quot;&gt;einvoicing&lt;/code&gt;, une gem Ruby open source qui génère des factures Factur-X, UBL et CII conformes à la norme EN 16931. Récemment, je lui ai fait passer un lot de quinze factures volontairement hostiles : multi-taux, autoliquidation, cinq cents lignes, taux d’outre-mer, devise étrangère. Le lot est passé au schéma XSD, aux contrôles du conteneur PDF/A-3 et à un jeu de règles EN 16931 recalculé à la main.&lt;/p&gt;

&lt;p&gt;Le lot a sorti quatre défauts, tous dans ma gem.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;960&quot; height=&quot;412&quot; src=&quot;/images/posts/tva-regime/mention-vs-code.fr.svg&quot; alt=&quot;À gauche, une facture PDF où le taux à zéro est expliqué par une phrase libre en bas de page. À droite, le même cas dans un flux structuré : une catégorie de TVA, un code de motif normalisé et son texte, sans lesquels le flux est rejeté.&quot; /&gt;
  &lt;figcaption&gt;La même exonération, écrite pour un lecteur à gauche et pour un validateur à droite.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;sept-régimes-pas-un-taux&quot;&gt;Sept régimes, pas un taux&lt;/h2&gt;

&lt;p&gt;La norme ne connaît pas « une facture à 0 % ». Elle connaît sept catégories de TVA, et le zéro peut venir de cinq d’entre elles pour des raisons juridiques différentes :&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Code&lt;/th&gt;
      &lt;th&gt;Régime&lt;/th&gt;
      &lt;th&gt;Cas courant&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;S&lt;/td&gt;
      &lt;td&gt;Taux normal ou réduit&lt;/td&gt;
      &lt;td&gt;La facture ordinaire&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Z&lt;/td&gt;
      &lt;td&gt;Taux zéro&lt;/td&gt;
      &lt;td&gt;Certaines livraisons taxées à 0 %&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;E&lt;/td&gt;
      &lt;td&gt;Exonéré&lt;/td&gt;
      &lt;td&gt;Franchise en base, acte médical, formation&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;AE&lt;/td&gt;
      &lt;td&gt;Autoliquidation&lt;/td&gt;
      &lt;td&gt;Sous-traitance dans le bâtiment&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;K&lt;/td&gt;
      &lt;td&gt;Livraison intracommunautaire&lt;/td&gt;
      &lt;td&gt;Client européen avec numéro de TVA valide&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;G&lt;/td&gt;
      &lt;td&gt;Export hors UE&lt;/td&gt;
      &lt;td&gt;Vente à un client suisse ou américain&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;O&lt;/td&gt;
      &lt;td&gt;Hors champ de la TVA&lt;/td&gt;
      &lt;td&gt;Certaines indemnités, subventions&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Ma gem s’arrêtait à trois : standard, taux zéro, autoliquidation. Tout le reste ressortait en « standard », sans bruit et sans erreur visible.&lt;/p&gt;

&lt;h2 id=&quot;le-champ-que-le-papier-ne-demandait-pas&quot;&gt;Le champ que le papier ne demandait pas&lt;/h2&gt;

&lt;p&gt;Quand une catégorie n’appelle pas de TVA, la norme exige un motif : un texte lisible et, selon les cas, un code européen. Cinq règles de conformité le vérifient, une par catégorie.&lt;/p&gt;

&lt;p&gt;Ma gem n’en écrivait aucun. Conséquence : &lt;strong&gt;toutes les factures en autoliquidation qu’elle émettait étaient non conformes&lt;/strong&gt;. Elles produisaient un PDF correct et un XML valide au schéma, tout en étant rejetables par la règle métier.&lt;/p&gt;

&lt;p&gt;Pour l’autoliquidation, l’intracommunautaire, l’export et le hors champ, le motif est mécanique : il existe un code européen dédié. Pour l’exonération simple, non. Le motif dépend de l’article invoqué, et une TPE en franchise en base n’est pas exonérée pour la même raison qu’un cabinet médical.&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;no&quot;&gt;Einvoicing&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;LineItem&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;description: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Prestation de conseil&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;quantity:    &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;unit_price:  &lt;/span&gt;&lt;span class=&quot;no&quot;&gt;BigDecimal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;2500.00&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;vat_rate:    &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;category:    :exempt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;exemption_reason:      &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;TVA non applicable, art. 293 B du CGI&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;ss&quot;&gt;exemption_reason_code: &lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Einvoicing&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Tax&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;VATEX_FR_FRANCHISE&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;C’est trois lignes de configuration. Encore faut-il que le logiciel qui émet la facture ait un endroit où les stocker, ce qui suppose qu’il modélise le régime et pas seulement le taux.&lt;/p&gt;

&lt;h2 id=&quot;trois-centimes-décart-et-la-facture-est-refusée&quot;&gt;Trois centimes d’écart, et la facture est refusée&lt;/h2&gt;

&lt;p&gt;Deuxième défaut, plus sournois. Le total de TVA d’une catégorie peut se calculer de deux façons : en additionnant la TVA arrondie de chaque ligne, ou en appliquant le taux à la base imposable de la catégorie.&lt;/p&gt;

&lt;p&gt;Sur une facture de cinq lignes, les deux méthodes donnent le même chiffre. Sur cinq cents lignes, elles divergent : trois centimes d’écart sur une catégorie, quinze sur une autre. La règle de contrôle se vérifie au centime près, et une facture « presque bonne » est refusée comme les autres.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;960&quot; height=&quot;380&quot; src=&quot;/images/posts/tva-regime/deux-arrondis.fr.svg&quot; alt=&quot;Une soustraction posée sur cinq cents lignes à 20 pour cent : la somme des TVA de ligne arrondies donne 4 231,47 euros, le taux appliqué à la base agrégée donne 4 231,44 euros, et l&#39;écart de trois centimes suffit à faire échouer le contrôle.&quot; /&gt;
  &lt;figcaption&gt;Les deux méthodes sont défendables. Le contrôle, lui, se vérifie au centime près.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;La norme tranche pour la seconde méthode : le montant de TVA d’une catégorie se déduit de sa base agrégée, et non de la somme des lignes. Une multiplication, un arrondi, à la fin.&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# EN 16931, BR-CO-17 : TVA de la catégorie = base de la catégorie x taux,&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# arrondi au centime. Les bases s&#39;additionnent donc en BigDecimal, sans&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# arrondi intermédiaire, et l&#39;arrondi ne tombe qu&#39;après la multiplication.&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sum&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:net_amount&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;      &lt;span class=&quot;c1&quot;&gt;# BigDecimal, aucun arrondi ici&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;vat&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;round&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;       &lt;span class=&quot;c1&quot;&gt;# rate vaut 0.20 pour 20 %, comme dans la gem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Tout arrondi placé plus haut dans la chaîne rouvre l’écart, ce qui explique que le défaut résiste au débogage ligne à ligne : prises une par une, les lignes sont justes.&lt;/p&gt;

&lt;p&gt;Et il ne se voit jamais en recette, parce que personne ne teste une facture de cinq cents lignes.&lt;/p&gt;

&lt;h2 id=&quot;les-taux-qui-existent-quand-même&quot;&gt;Les taux qui existent quand même&lt;/h2&gt;

&lt;p&gt;20, 10, 5,5 et 0. Ma liste de taux français s’arrêtait là, comme dans à peu près tous les tutoriels. Elle refusait donc 2,1 %, le taux de la presse et des médicaments remboursables. Elle refusait 8,5 %, le taux normal en Guadeloupe, en Martinique et à La Réunion. Elle refusait les taux corses. Tous parfaitement légaux.&lt;/p&gt;

&lt;p&gt;Même famille d’erreur sur les numéros de TVA. Mon validateur imposait le format français à tout le monde, acheteur compris. Une facture intracommunautaire vers l’Allemagne échouait donc sur le numéro de TVA du client, qui était pourtant valide.&lt;/p&gt;

&lt;p&gt;Le point commun de ces deux bugs est un référentiel écrit depuis le cas courant. Ça tient jusqu’au jour où un utilisateur vend en Corse ou achète depuis Munich.&lt;/p&gt;

&lt;h2 id=&quot;pourquoi-ça-devient-un-problème-dentreprise&quot;&gt;Pourquoi ça devient un problème d’entreprise&lt;/h2&gt;

&lt;p&gt;Aucun de ces cas n’est exotique. La franchise en base concerne des centaines de milliers de très petites entreprises. L’autoliquidation est la règle en sous-traitance du bâtiment. Les taux d’outre-mer, ce sont plusieurs régions. La devise étrangère arrive au premier client hors zone euro.&lt;/p&gt;

&lt;p&gt;Chacun de ces cas est le quotidien de quelqu’un.&lt;/p&gt;

&lt;p&gt;Aujourd’hui, ces factures partent sans que personne ne se plaigne, parce qu’un humain les lit à l’arrivée et corrige mentalement ce qui manque. À partir du moment où c’est une machine qui les reçoit, il n’y a plus de correction mentale. Il y a un rejet, un retard de paiement, et un client qui vous demande de renvoyer.&lt;/p&gt;

&lt;p&gt;La réception devient obligatoire en septembre 2026, l’émission en septembre 2027 pour les TPE et PME. Le premier rejet, lui, arrivera avant l’échéance qui figure au calendrier.&lt;/p&gt;

&lt;h2 id=&quot;la-question-à-poser-à-son-éditeur&quot;&gt;La question à poser à son éditeur&lt;/h2&gt;

&lt;p&gt;Pas « serez-vous prêts pour 2027 ? ». Tout le monde répond oui.&lt;/p&gt;

&lt;p&gt;Plutôt : « qu’est-ce que votre logiciel écrit dans le champ motif d’exonération quand j’émets une facture en autoliquidation ? ». S’il n’y a pas de réponse, c’est qu’il n’y a pas de champ.&lt;/p&gt;

&lt;p&gt;Et pour lever le doute sur une facture précise, il y a plus rapide que la théorie : &lt;a href=&quot;/verifier-facture-electronique/&quot;&gt;la passer dans un validateur&lt;/a&gt;, analyse locale, sans upload. Si vous voulez faire le tour de votre chaîne complète, &lt;a href=&quot;/facturation-electronique-2026/&quot;&gt;la page facturation électronique 2026&lt;/a&gt; décrit le diagnostic que je propose.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-jen-retiens&quot;&gt;Ce que j’en retiens&lt;/h2&gt;

&lt;p&gt;Ma conclusion est un peu inconfortable. J’écris cette gem depuis des mois, je connais la norme, et j’ai quand même émis des factures non conformes pendant tout ce temps sans qu’aucun test ne bronche.&lt;/p&gt;

&lt;p&gt;La norme n’est pas piégeuse en elle-même : un XML valide au schéma peut être invalide au métier, et rien ne vous le dira tant que personne n’aura écrit les factures qui font mal. Le lot de quinze factures hostiles a coûté une demi-journée. Il aurait coûté beaucoup plus cher en septembre 2027.&lt;/p&gt;

&lt;p&gt;Tout est corrigé dans la version 0.9.0 de la gem, publiée dans la foulée.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opensource" />
    <category term="E-invoicing" />
    <category term="Factur-X" />
    <category term="TVA" />
    <category term="EN 16931" />
    <category term="Conformité" />
    <summary type="html">Quinze factures hostiles passées au validateur, et quatre défauts dans ma propre gem. Ce qui casse en facture électronique, c&#39;est le régime de TVA.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-09-03-tva-regime-pas-taux-facture-electronique.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Quand le code décrit une fonction qui n&#39;existe pas</title>
    <link href="https://sxnlabs.com/programming/2026/08/24/le-code-mort-est-une-documentation-fausse/" rel="alternate" type="text/html" title="Quand le code décrit une fonction qui n&#39;existe pas" />
    <published>2026-08-24T09:00:00+02:00</published>
    <updated>2026-08-24T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/programming/2026/08/24/le-code-mort-est-une-documentation-fausse/</id>
    <content type="html" xml:base="https://sxnlabs.com/programming/2026/08/24/le-code-mort-est-une-documentation-fausse/">&lt;p&gt;Lors de l’audit d’une application Rails, j’ai examiné son parcours de partage de rapports. L’application générait une URL que l’utilisateur pouvait transmettre à un destinataire pour lui donner accès au document. Si cette URL partait au mauvais destinataire ou si l’accès devait prendre fin, il fallait pouvoir l’invalider.&lt;/p&gt;

&lt;p&gt;Le projet semblait avoir prévu le cas. Un commentaire présentait le lien comme révocable et une méthode régénérait son token, ce qui devait rendre l’ancienne URL inutilisable. Pourtant, le produit ne proposait aucun bouton de révocation et rien ne déclenchait la méthode.&lt;/p&gt;

&lt;p&gt;En lisant seulement le code, la conclusion paraissait évidente : la révocation était cassée. En suivant le produit, une autre hypothèse apparaissait : cette fonction n’avait peut-être jamais existé.&lt;/p&gt;

&lt;h2 id=&quot;une-fonction-présente-uniquement-dans-le-dépôt&quot;&gt;Une fonction présente uniquement dans le dépôt&lt;/h2&gt;

&lt;p&gt;J’ai repris le parcours depuis son point d’entrée. Une route créait le partage, le contrôleur préparait le lien et l’interface permettait de le transmettre. Le chemin s’arrêtait là, sans action pour invalider l’URL ni écran pour en produire une nouvelle.&lt;/p&gt;

&lt;p&gt;La méthode de régénération ne complétait donc aucun parcours utilisateur. Son nom et son commentaire décrivaient une fonction cohérente, mais elle restait isolée du produit. Cette différence importe pendant un audit : signaler une révocation défectueuse suppose qu’un utilisateur ait pu compter sur elle, tandis qu’une implémentation orpheline indique une fonction abandonnée ou jamais terminée.&lt;/p&gt;

&lt;p&gt;L’explication la plus probable était une demande client prise trop littéralement. « Il faut pouvoir révoquer un lien » ressemble à un besoin, mais décrit déjà une solution. Le problème réel pouvait être un lien envoyé au mauvais destinataire, un accès trop long ou des droits mal définis. Aucun de ces cas n’avait été cadré, la méthode était restée après l’abandon du sujet.&lt;/p&gt;

&lt;p&gt;Le même écart fausse ensuite les estimations. Une équipe qui découvre le dépôt peut chiffrer une évolution comme si la révocation existait déjà, puis apprendre trop tard qu’il reste à concevoir les droits, l’interface et le comportement des liens envoyés.&lt;/p&gt;

&lt;h2 id=&quot;prouver-que-le-code-est-inaccessible&quot;&gt;Prouver que le code est inaccessible&lt;/h2&gt;

&lt;p&gt;Une recherche sans appelant ne suffit pas toujours dans une application Rails. Une méthode peut être invoquée par un callback, un nom construit dynamiquement, un &lt;code class=&quot;highlighter-rouge&quot;&gt;public_send&lt;/code&gt; ou une tâche éloignée de son modèle. J’ai donc vérifié le code depuis les points d’entrée réellement utilisés.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;reproduire la création et l’utilisation du lien depuis l’interface ;&lt;/li&gt;
  &lt;li&gt;suivre la route, le contrôleur, les vues et les traitements différés ;&lt;/li&gt;
  &lt;li&gt;chercher les références directes à la méthode et au nom du token ;&lt;/li&gt;
  &lt;li&gt;examiner les appels dynamiques capables d’échapper à la recherche textuelle ;&lt;/li&gt;
  &lt;li&gt;instrumenter temporairement la méthode si le trafic de production laisse encore un doute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cette preuve permet une suppression limitée : la méthode, son commentaire et les tests qui ne couvrent qu’elle peuvent disparaître ensemble. Les tests du parcours de partage vérifient ensuite que la création et l’ouverture d’un lien continuent de fonctionner. Git conserve l’ancienne implémentation si l’enquête se révèle incomplète, sans obliger le dépôt à la présenter comme du code actuel.&lt;/p&gt;

&lt;h2 id=&quot;supprimer-le-code-conserver-le-besoin&quot;&gt;Supprimer le code, conserver le besoin&lt;/h2&gt;

&lt;p&gt;La méthode morte ne rendait pas la révocation disponible, mais le besoin restait valable. Le supprimer avec son implémentation aurait confondu deux décisions différentes : nettoyer le dépôt maintenant et choisir si le produit doit permettre la révocation.&lt;/p&gt;

&lt;p&gt;Un vrai ticket de fonctionnalité doit encore préciser qui peut révoquer un lien, ce que voit son destinataire après l’invalidation, si un nouveau lien est créé automatiquement et comment l’interface confirme l’opération. Garder une méthode sans appelant ne répond à aucune de ces questions et donne seulement l’impression que le travail est presque terminé.&lt;/p&gt;

&lt;p&gt;Le code orphelin peut donc être supprimé immédiatement, tandis que le besoin rejoint la roadmap avec son périmètre réel. Si la révocation est priorisée plus tard, son implémentation partira du parcours attendu plutôt que d’une méthode retrouvée par hasard.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-laudit-doit-conclure&quot;&gt;Ce que l’audit doit conclure&lt;/h2&gt;

&lt;p&gt;Le rapport ne devait annoncer ni une faille corrigée ni une fonction réparée. Il devait établir que les liens n’avaient jamais été révocables, que la méthode de régénération n’avait aucun appelant et que sa suppression ne modifiait pas le comportement du produit.&lt;/p&gt;

&lt;p&gt;Cette conclusion laisse deux éléments exploitables : la preuve qui autorise le nettoyage et un besoin produit qui peut être arbitré séparément. Le prochain lecteur ne confondra plus une intention abandonnée avec une capacité disponible.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="programming" />
    <category term="Méthode" />
    <category term="Dette technique" />
    <category term="Audit" />
    <category term="Rails" />
    <summary type="html">Une méthode de révocation sans appelant transforme une fonction absente en faux bug. L&#39;audit doit prouver l&#39;écart, supprimer le code et conserver le besoin.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-08-24-le-code-mort-est-une-documentation-fausse.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Turbo n’a pas trois outils, il a trois scopes</title>
    <link href="https://sxnlabs.com/ruby/2026/08/06/turbo-drive-frames-streams-reference/" rel="alternate" type="text/html" title="Turbo n’a pas trois outils, il a trois scopes" />
    <published>2026-08-06T09:00:00+02:00</published>
    <updated>2026-08-06T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/ruby/2026/08/06/turbo-drive-frames-streams-reference/</id>
    <content type="html" xml:base="https://sxnlabs.com/ruby/2026/08/06/turbo-drive-frames-streams-reference/">&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Pour choisir, je regarde deux choses : &lt;strong&gt;la portion du document qui change&lt;/strong&gt; et &lt;strong&gt;le moment où sa cible est décidée&lt;/strong&gt;. 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 &lt;strong&gt;périmètres&lt;/strong&gt; de mutation : la page entière, un fragment nommé, ou un ensemble d’éléments désignés dans une réponse.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;remove&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2 id=&quot;versions-et-niveau-de-garantie&quot;&gt;Versions et niveau de garantie&lt;/h2&gt;

&lt;p&gt;Cet article cible &lt;strong&gt;Turbo 8.0.23&lt;/strong&gt; et &lt;strong&gt;turbo-rails 2.0.23&lt;/strong&gt;, 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 :&lt;/p&gt;

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

&lt;p&gt;Les liens vers le code de Turbo et turbo-rails pointent sur les tags &lt;code class=&quot;highlighter-rouge&quot;&gt;v8.0.23&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;v2.0.23&lt;/code&gt;, donc sur des lignes figées : elles diront toujours ce que je leur fais dire, même quand la branche &lt;code class=&quot;highlighter-rouge&quot;&gt;main&lt;/code&gt; aura bougé.&lt;/p&gt;

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

&lt;h2 id=&quot;le-modèle-en-une-page&quot;&gt;Le modèle en une page&lt;/h2&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;940&quot; height=&quot;562&quot; src=&quot;/images/posts/turbo/01-trois-scopes.svg?v=20260809-signature&quot; alt=&quot;Trois panneaux comparant Turbo Drive qui remplace la page entière, Turbo Frames qui remplace un fragment désigné par le client avant l&#39;envoi de la requête, et Turbo Streams qui applique des ordres à des cibles nommées par le serveur dans la réponse.&quot; /&gt;
  &lt;figcaption&gt;La même mécanique, trois scopes. Ce qui change d&#39;une colonne à l&#39;autre : l&#39;étendue du remplacement, qui en désigne la cible, et à quel moment.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Avec &lt;strong&gt;Drive&lt;/strong&gt;, la cible est implicite : c’est le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Avec &lt;strong&gt;Frames&lt;/strong&gt;, la cible est décidée par le client &lt;strong&gt;avant même que la requête parte&lt;/strong&gt;. Le frame à l’origine de la requête envoie son identifiant dans le header &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo-Frame&lt;/code&gt;. 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;recurse&lt;/code&gt;. Le serveur ne peut pas rediriger le contenu vers un autre frame. Il peut seulement court-circuiter l’extraction avec &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt;, qui transforme la réponse en visite pleine page.&lt;/p&gt;

&lt;p&gt;Avec &lt;strong&gt;Streams&lt;/strong&gt;, la cible est écrite &lt;strong&gt;dans la réponse&lt;/strong&gt;, donc décidée au dernier moment, par le serveur. Il dit &lt;code class=&quot;highlighter-rouge&quot;&gt;remplace l&#39;élément qui a cet id&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;ajoute ceci à la fin de celui-là&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;supprime cet autre&lt;/code&gt;. Il peut viser plusieurs endroits non contigus, et il peut le faire sans que personne n’ait rien demandé, via un WebSocket.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-retenir&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;À retenir&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;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 &lt;code class=&quot;highlighter-rouge&quot;&gt;recurse&lt;/code&gt; ; sinon Turbo émet &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt; puis, par défaut, affiche « Content missing » et lève une erreur. Un listener peut annuler l’événement pour prendre le relais. &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt; contourne entièrement ce chemin avec une visite pleine page. Pour un stream, le contrat est &lt;em&gt;la cible doit exister dans le document au moment où l’ordre arrive&lt;/em&gt; ; une cible absente est, elle, un no-op silencieux.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;quelle-primitive-choisir-&quot;&gt;Quelle primitive choisir ?&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Votre besoin&lt;/th&gt;
      &lt;th&gt;La primitive&lt;/th&gt;
      &lt;th&gt;Pourquoi ce choix&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;L’état est purement client et le serveur n’a rien à en savoir&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Stimulus, sans Turbo&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Un aller-retour réseau pour ouvrir un menu est un aller-retour de trop&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;L’URL et la page entière représentent le nouvel état&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Drive&lt;/strong&gt;, c’est-à-dire rien à écrire&lt;/td&gt;
      &lt;td&gt;C’est déjà actif et fournit l’historique ainsi que le bouton retour&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Une seule zone change, et une URL répond avec un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; de même identifiant&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Frame&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Turbo gère nativement le lazy loading (&lt;code class=&quot;highlighter-rouge&quot;&gt;loading=&quot;lazy&quot;&lt;/code&gt;), le loading state et la navigation interne. &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-action&lt;/code&gt; peut promouvoir cette navigation dans l’historique&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Plusieurs zones non contiguës changent en réponse à une action de l’utilisateur&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Réponse &lt;code class=&quot;highlighter-rouge&quot;&gt;.turbo_stream&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Un frame ne peut viser qu’un fragment. Découper la page en cinq frames pour simuler un stream multiplie les requêtes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Le changement doit atteindre un navigateur qui n’a pas lancé la requête courante&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Broadcast&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;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 &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; exploitable&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Reste le cas où les deux semblent possibles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le test de l’URL, pour les frames.&lt;/strong&gt; S’il n’existe pas d’URL dont la réponse contient ce fragment sous un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-action&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le test du destinataire, pour les streams.&lt;/strong&gt; Si seul l’onglet qui vient d’agir doit changer, c’est une réponse &lt;code class=&quot;highlighter-rouge&quot;&gt;.turbo_stream&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2 id=&quot;turbo-drive&quot;&gt;Turbo Drive&lt;/h2&gt;

&lt;p&gt;Drive est actif dès que vous chargez &lt;code class=&quot;highlighter-rouge&quot;&gt;@hotwired/turbo&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3 id=&quot;ce-qui-est-intercepté-et-ce-qui-ne-lest-pas&quot;&gt;Ce qui est intercepté, et ce qui ne l’est pas&lt;/h3&gt;

&lt;p&gt;Drive intercepte les clics principaux sans touche de modification sur les &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; navigables, ainsi que les form submissions navigables, tant que leur destination est visitable. Une URL visitable reste sous le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;meta name=&quot;turbo-root&quot;&amp;gt;&lt;/code&gt; de la page (&lt;code class=&quot;highlighter-rouge&quot;&gt;/&lt;/code&gt; par défaut) et son extension ne figure pas dans &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.drive.unvisitableExtensions&lt;/code&gt; (une cinquantaine d’extensions, dont &lt;code class=&quot;highlighter-rouge&quot;&gt;.pdf&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;.zip&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;.csv&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;.jpg&lt;/code&gt;). Les URL cross-origin sont donc exclues avec le &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-root&lt;/code&gt; par défaut. Cette liste d’extensions est configurable et n’est documentée nulle part sur le site officiel.&lt;/p&gt;

&lt;p&gt;Turbo laisse aussi le browser gérer les liens &lt;code class=&quot;highlighter-rouge&quot;&gt;download&lt;/code&gt;, tous les liens dont &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; diffère de &lt;code class=&quot;highlighter-rouge&quot;&gt;_self&lt;/code&gt;, ainsi que les formulaires en &lt;code class=&quot;highlighter-rouge&quot;&gt;method=&quot;dialog&quot;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Vous coupez ponctuellement avec &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo=&quot;false&quot;&lt;/code&gt; sur l’élément ou n’importe lequel de ses ancêtres. Un &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo=&quot;true&quot;&lt;/code&gt; imbriqué plus bas le réactive.&lt;/p&gt;

&lt;h3 id=&quot;le-cycle-de-vie&quot;&gt;Le cycle de vie&lt;/h3&gt;

&lt;p&gt;Il n’existe pas de séquence universelle. Le tableau suit un link en &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt; 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, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:load&lt;/code&gt; est émis sans &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:render&lt;/code&gt;. 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.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Événement&lt;/th&gt;
      &lt;th&gt;Émis sur&lt;/th&gt;
      &lt;th&gt;Annulable&lt;/th&gt;
      &lt;th&gt;Ce qu’il permet vraiment&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:click&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a&amp;gt;&lt;/code&gt; cliqué&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Annuler pour laisser le navigateur faire une navigation classique&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-visit&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Le dernier endroit où l’on peut refuser la visite&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;/code&gt; sur une visite, le frame ou le formulaire concerné, le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a&amp;gt;&lt;/code&gt; sur un prefetch ou un preload&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;preventDefault()&lt;/code&gt; &lt;strong&gt;ne bloque pas la requête, il la met en pause&lt;/strong&gt; jusqu’à l’appel de &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.resume()&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:visit&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Informatif. &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.action&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;restore&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-response&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;/code&gt; sur une visite, le frame ou le formulaire concerné, le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a&amp;gt;&lt;/code&gt; sur un prefetch ou un preload&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Annuler empêche le delegate Visit/Frame/FormSubmission de traiter la réponse. Cela ne bloque pas à lui seul un turbo-stream : &lt;code class=&quot;highlighter-rouge&quot;&gt;StreamObserver&lt;/code&gt; ignore &lt;code class=&quot;highlighter-rouge&quot;&gt;defaultPrevented&lt;/code&gt; et peut l’appliquer depuis ce même événement&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Nettoyer le DOM avant le cache. Le clone est différé au prochain event loop tick&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-render&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Même sémantique de pause. &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.newBody&lt;/code&gt; est modifiable avant rendu, &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.renderMethod&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:render&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Le nouveau corps est en place&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:load&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Fin de la visite. Au premier chargement, Turbo l’émet quand &lt;code class=&quot;highlighter-rouge&quot;&gt;readystatechange&lt;/code&gt; passe à &lt;code class=&quot;highlighter-rouge&quot;&gt;interactive&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;complete&lt;/code&gt; ; un bundle async chargé après &lt;code class=&quot;highlighter-rouge&quot;&gt;DOMContentLoaded&lt;/code&gt; peut démarrer trop tard et le manquer&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Première surprise, &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt; précède &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:visit&lt;/code&gt;&lt;/strong&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;Visit#start()&lt;/code&gt; appelle &lt;code class=&quot;highlighter-rouge&quot;&gt;this.adapter.visitStarted(this)&lt;/code&gt;, qui prépare la requête, &lt;strong&gt;avant&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;this.delegate.visitStarted(this)&lt;/code&gt;, qui émet &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:visit&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L114-L118&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;visit.js#L114-L118&lt;/code&gt;&lt;/a&gt;). Le &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch()&lt;/code&gt; effectif attend encore la fin de l’interception, mais un listener installé depuis &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:visit&lt;/code&gt; a déjà manqué &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt;. Instrumentez cet événement directement si vous devez observer ou modifier toutes les requêtes.&lt;/p&gt;

&lt;p&gt;L’autre surprise tient à un défaut de &lt;code class=&quot;highlighter-rouge&quot;&gt;dispatch()&lt;/code&gt; : sans &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; explicite, il émet sur &lt;code class=&quot;highlighter-rouge&quot;&gt;document.documentElement&lt;/code&gt;, pas sur &lt;code class=&quot;highlighter-rouge&quot;&gt;document&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/util.js#L29-L44&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;util.js#L29-L44&lt;/code&gt;&lt;/a&gt;). Avec l’event bubbling, écouter sur &lt;code class=&quot;highlighter-rouge&quot;&gt;document&lt;/code&gt; fonctionne, mais &lt;code class=&quot;highlighter-rouge&quot;&gt;event.target&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;/code&gt;, 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;form&amp;gt;&lt;/code&gt;, et ceux du prefetch ou du preload ciblent le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Et les événements propres aux frames et aux formulaires, qui s’intercalent dans la même séquence :&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Événement&lt;/th&gt;
      &lt;th&gt;Émis sur&lt;/th&gt;
      &lt;th&gt;Annulable&lt;/th&gt;
      &lt;th&gt;Ce qu’il permet vraiment&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-frame-render&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;le frame&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Pause, et &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.render&lt;/code&gt; est remplaçable : c’est le point d’entrée officiel pour brancher un autre moteur de rendu&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-render&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;le frame&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Déclaré &lt;code class=&quot;highlighter-rouge&quot;&gt;cancelable: true&lt;/code&gt;, mais la valeur de retour du dispatch est jetée : l’annuler ne produit rien&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-load&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;le frame&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Le frame a fini&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:submit-start&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;form&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;detail.formSubmission&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:submit-end&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;form&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Toujours &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.formSubmission&lt;/code&gt;. Une réponse traitée par &lt;code class=&quot;highlighter-rouge&quot;&gt;FormSubmission&lt;/code&gt; ajoute &lt;code class=&quot;highlighter-rouge&quot;&gt;success&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;fetchResponse&lt;/code&gt; ; une network error ajoute &lt;code class=&quot;highlighter-rouge&quot;&gt;success: false&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;error&lt;/code&gt;. Ces clés restent absentes après un abort, ainsi qu’après le guard &lt;code class=&quot;highlighter-rouge&quot;&gt;Form responses must redirect&lt;/code&gt; d’une réponse unsafe 200 non redirigée.&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-observe&quot;&gt;vérifié&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt; 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 : &lt;code class=&quot;highlighter-rouge&quot;&gt;this.url = event.detail.url&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/http/fetch_request.js#L183-L199&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;fetch_request.js#L183-L199&lt;/code&gt;&lt;/a&gt;), et c’est bien ce &lt;code class=&quot;highlighter-rouge&quot;&gt;this.url&lt;/code&gt; qui part au &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch&lt;/code&gt; juste après. Réécrire &lt;code class=&quot;highlighter-rouge&quot;&gt;event.detail.url&lt;/code&gt; 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.&lt;/p&gt;

  &lt;p&gt;Encore faut-il &lt;strong&gt;le faire de façon synchrone dans le listener&lt;/strong&gt; : l’affectation a lieu &lt;em&gt;avant&lt;/em&gt; le &lt;code class=&quot;highlighter-rouge&quot;&gt;await&lt;/code&gt; de la pause, donc modifier &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.url&lt;/code&gt; après un &lt;code class=&quot;highlighter-rouge&quot;&gt;preventDefault()&lt;/code&gt; et avant &lt;code class=&quot;highlighter-rouge&quot;&gt;resume()&lt;/code&gt; n’a aucun effet. Et &lt;strong&gt;affectez un objet &lt;code class=&quot;highlighter-rouge&quot;&gt;URL&lt;/code&gt;, pas une chaîne&lt;/strong&gt; : Turbo lit &lt;code class=&quot;highlighter-rouge&quot;&gt;this.url.href&lt;/code&gt;, une chaîne donne &lt;code class=&quot;highlighter-rouge&quot;&gt;undefined&lt;/code&gt;. Ce n’est pas dans le handbook, mais c’est couvert par la suite de tests du dépôt (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/tests/functional/form_submission_tests.js#L191-L204&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;form_submission_tests.js#L191-L204&lt;/code&gt;&lt;/a&gt;), ce qui en fait une garantie plus solide qu’il n’y paraît.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Le reste des bugs de cycle de vie vient presque toujours du snapshot ou de la preview.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le snapshot vient de la page qu’on quitte, pas de celle qu’on charge.&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; se déclenche sur le document courant, puis &lt;code class=&quot;highlighter-rouge&quot;&gt;PageView#cacheSnapshot()&lt;/code&gt; attend le prochain event loop tick avant le &lt;code class=&quot;highlighter-rouge&quot;&gt;cloneNode(true)&lt;/code&gt;. Le nettoyage synchrone dans ce hook reste le chemin déterministe. En revanche, la visite n’attend pas la fin de &lt;code class=&quot;highlighter-rouge&quot;&gt;cacheSnapshot()&lt;/code&gt; : le remplacement du &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; et un &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; Stimulus peuvent donc modifier l’ancien DOM avant le clone. Un &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; n’arrive pas nécessairement trop tard pour nettoyer le cache.&lt;/p&gt;

&lt;p&gt;Le clone &lt;strong&gt;perd les listeners&lt;/strong&gt; mais &lt;strong&gt;garde le DOM injecté&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Le clone remet aussi la sélection des &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;select&amp;gt;&lt;/code&gt; (que &lt;code class=&quot;highlighter-rouge&quot;&gt;cloneNode&lt;/code&gt; perd), vide la valeur de tous les &lt;code class=&quot;highlighter-rouge&quot;&gt;input[type=password]&lt;/code&gt;, et supprime les &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;noscript&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Une preview depuis le cache peut déclencher &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:render&lt;/code&gt; deux fois, mais pas sur le retour arrière.&lt;/strong&gt; Sur une visite &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt;, 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:render&lt;/code&gt; suit. Pendant le premier render, &lt;code class=&quot;highlighter-rouge&quot;&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;/code&gt; porte l’attribut &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-preview&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;une seule fois&lt;/strong&gt;. Sans snapshot utilisable, elle repart sur le network. &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; décrivent des actions d’historique, pas seulement des clics de lien ou des form submissions : &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.visit()&lt;/code&gt; et un page refresh peuvent aussi les produire.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;interne&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Tout tient dans une méthode de six lignes : &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldIssueRequest()&lt;/code&gt; renvoie &lt;code class=&quot;highlighter-rouge&quot;&gt;!this.hasCachedSnapshot()&lt;/code&gt; quand &lt;code class=&quot;highlighter-rouge&quot;&gt;this.action == &quot;restore&quot;&lt;/code&gt;, et &lt;code class=&quot;highlighter-rouge&quot;&gt;this.willRender&lt;/code&gt; sinon (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L377-L383&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;visit.js#L377-L383&lt;/code&gt;&lt;/a&gt;). Or &lt;code class=&quot;highlighter-rouge&quot;&gt;loadCachedSnapshot&lt;/code&gt; calcule sa preview par &lt;code class=&quot;highlighter-rouge&quot;&gt;const isPreview = this.shouldIssueRequest()&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L245-L265&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;visit.js#L245-L265&lt;/code&gt;&lt;/a&gt;). Pas de requête, donc pas de preview, donc un seul rendu.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;En pratique, la guard clause à écrire dans un contrôleur Stimulus reste la même :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nf&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;documentElement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;hasAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;data-turbo-preview&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;initExpensiveWidget&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;le-cache-en-chiffres&quot;&gt;Le cache, en chiffres&lt;/h3&gt;

&lt;p&gt;Le cache Drive est un LRU de &lt;strong&gt;dix snapshots&lt;/strong&gt;, en mémoire, dans l’onglet. Pas de &lt;code class=&quot;highlighter-rouge&quot;&gt;localStorage&lt;/code&gt;, pas d’IndexedDB, pas de Cache API. Il meurt à la fermeture de l’onglet et au rechargement. Chaque entrée conserve un clone du &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; et un index du &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, y compris les balises &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;link&amp;gt;&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt;. Elle ne conserve pas les octets des images, stylesheets ou scripts : leur réutilisation relève du cache HTTP du browser.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-retenir&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;À retenir&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;Le cache de Turbo est une optimisation de latence perçue, pas une stratégie hors ligne.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;interne&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Le vidage est asymétrique. Sur le chemin de succès pleine page, &lt;code class=&quot;highlighter-rouge&quot;&gt;clearSnapshotCache()&lt;/code&gt; n’est appelé que si &lt;code class=&quot;highlighter-rouge&quot;&gt;!formSubmission.isSafe&lt;/code&gt;, &lt;strong&gt;et&lt;/strong&gt; seulement à l’intérieur du &lt;code class=&quot;highlighter-rouge&quot;&gt;if (responseHTML)&lt;/code&gt; : une soumission non-GET dont la réponse n’est pas du HTML ne vide rien (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/navigator.js#L71-L90&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;navigator.js#L71-L90&lt;/code&gt;&lt;/a&gt;). Sur le chemin d’échec pleine page, le cache n’est vidé que si &lt;code class=&quot;highlighter-rouge&quot;&gt;responseHTML&lt;/code&gt; existe, y compris pour un formulaire &lt;code class=&quot;highlighter-rouge&quot;&gt;GET&lt;/code&gt; qui revient en HTML 4xx ou 5xx (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/navigator.js#L92-L107&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;navigator.js#L92-L107&lt;/code&gt;&lt;/a&gt;). 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 (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/frames/frame_controller.js#L246-L260&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;frame_controller.js#L246-L260&lt;/code&gt;&lt;/a&gt;). Une network error passe par &lt;code class=&quot;highlighter-rouge&quot;&gt;formSubmissionErrored()&lt;/code&gt; et ne le vide pas ; un échec de navigation par link ou &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; non plus.&lt;/p&gt;

  &lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reste ce que vous pouvez régler vous-même :&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Ce que vous voulez&lt;/th&gt;
      &lt;th&gt;Comment&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Ne jamais mettre cette page en cache&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;meta name=&quot;turbo-cache-control&quot; content=&quot;no-cache&quot;&amp;gt;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;La mettre en cache mais ne jamais l’afficher en preview&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;meta name=&quot;turbo-cache-control&quot; content=&quot;no-preview&quot;&amp;gt;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Retirer un élément avant la mise en cache&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-temporary&lt;/code&gt; sur l’élément&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-cache=&quot;false&quot;&lt;/code&gt; a été &lt;strong&gt;supprimé en 8.0.21&lt;/strong&gt;, en janvier 2026, après trois ans de dépréciation. &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.clearCache()&lt;/code&gt; aussi, remplacé par &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.cache.clear()&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3 id=&quot;turboconfig&quot;&gt;Turbo.config&lt;/h3&gt;

&lt;p&gt;Depuis 8.0.6, la configuration passe par un objet unique. L’ancienne API de configuration (&lt;code class=&quot;highlighter-rouge&quot;&gt;setProgressBarDelay&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;setConfirmMethod&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;setFormMode&lt;/code&gt;) existe toujours mais émet un avertissement en console.&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;Turbo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;drive&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;progressBarDelay&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;500&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;// ms avant la barre de progression&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;Turbo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;drive&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;enabled&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;Turbo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;forms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;mode&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;         &lt;span class=&quot;c1&quot;&gt;// on | off | optin&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;Turbo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;forms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;submitter&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;disabled&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;// disabled | aria-disabled&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;Turbo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;forms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;confirm&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;confirm&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-observe&quot;&gt;vérifié&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Sur ces cinq lignes, deux sont documentées sur &lt;a href=&quot;https://turbo.hotwired.dev/reference/drive&quot;&gt;la page Drive de la référence&lt;/a&gt; : &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.drive.progressBarDelay&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.forms.confirm&lt;/code&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;drive.enabled&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;forms.mode&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;forms.submitter&lt;/code&gt; n’apparaissent nulle part sur &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo.hotwired.dev&lt;/code&gt;. Les trois modes se déduisent des deux comparaisons d’égalité de &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#submissionIsNavigatable&lt;/code&gt; plus le défaut &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;on&quot;&lt;/code&gt; ; il n’y a pas d’énumération dans le code.&lt;/p&gt;

  &lt;p&gt;Un détail qui va avec, et qui mord : &lt;code class=&quot;highlighter-rouge&quot;&gt;set submitter(value) { this.#submitter = submitter[value] || value }&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/config/forms.js#L33-L35&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;config/forms.js#L33-L35&lt;/code&gt;&lt;/a&gt;). Une chaîne non reconnue est stockée telle quelle, sans erreur, et explose plus tard sur &lt;code class=&quot;highlighter-rouge&quot;&gt;config.forms.submitter.beforeSubmit(...)&lt;/code&gt;. Une faute de frappe ne se voit qu’à la première soumission &lt;strong&gt;qui possède un submitter&lt;/strong&gt; ; une soumission déclenchée sans submitter ne passe pas par cet appel.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;forms.submitter&lt;/code&gt; mérite un mot. Par défaut, Turbo pose &lt;code class=&quot;highlighter-rouge&quot;&gt;disabled&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; et désorienter la personne qui navigue au clavier ou avec un screen reader. Avec &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;aria-disabled&quot;&lt;/code&gt;, Turbo pose &lt;code class=&quot;highlighter-rouge&quot;&gt;aria-disabled=&quot;true&quot;&lt;/code&gt; et intercepte les &lt;code class=&quot;highlighter-rouge&quot;&gt;click&lt;/code&gt; sur le submitter, qui reste focusable. Cela bloque un second click traité par Turbo, pas une submission déclenchée directement par du code.&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;Turbo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;forms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;submitter&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;aria-disabled&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Si votre interface doit conserver le focus pendant la submission, cette ligne dans &lt;code class=&quot;highlighter-rouge&quot;&gt;application.js&lt;/code&gt; est un meilleur default. Il faut simplement prévoir le style visuel de &lt;code class=&quot;highlighter-rouge&quot;&gt;aria-disabled&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;prefetch-et-preload&quot;&gt;Prefetch et preload&lt;/h3&gt;

&lt;p&gt;Depuis Turbo 8, le prefetch au survol est &lt;strong&gt;actif par défaut&lt;/strong&gt;. Pour un link éligible, Turbo attend 100 ms après le &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt;, envoie un GET avec l’en-tête &lt;code class=&quot;highlighter-rouge&quot;&gt;X-Sec-Purpose: prefetch&lt;/code&gt;, et garde la réponse dans un cache d’une seule entrée pendant 10 secondes par défaut. La meta &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-prefetch-cache-time&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Le prefetch ne s’applique pas à tous les links. Il faut notamment une URL HTTP(S) same-origin et visitable, sans &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;download&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo=&quot;false&quot;&lt;/code&gt;, méthode unsafe, stream, confirmation ni attribut UJS ; le link vers la page courante est également exclu. &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-prefetch&lt;/code&gt; 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 :&lt;/p&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;meta&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;turbo-prefetch&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;content=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;false&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;     &lt;span class=&quot;c&quot;&gt;&amp;lt;%# global %&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;a&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;href=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/x&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-turbo-prefetch=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;false&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;…&lt;span class=&quot;nt&quot;&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;   &lt;span class=&quot;c&quot;&gt;&amp;lt;%# ponctuel %&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;À ne pas confondre avec &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-preload&lt;/code&gt;, qui ne s’applique qu’aux &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2 id=&quot;turbo-frames&quot;&gt;Turbo Frames&lt;/h2&gt;

&lt;h3 id=&quot;tout-tient-dans-un-identifiant&quot;&gt;Tout tient dans un identifiant&lt;/h3&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;940&quot; height=&quot;646&quot; src=&quot;/images/posts/turbo/02-contrat-frame.svg?v=20260809-signature&quot; alt=&quot;La requête porte l&#39;en-tête Turbo-Frame. La réponse peut être n&#39;importe quel HTML : Turbo applique d&#39;abord turbo-visit-control, puis cherche le frame directement ou via recurse. Sans correspondance, il émet turbo:frame-missing.&quot; /&gt;
  &lt;figcaption&gt;Après &lt;code&gt;turbo-visit-control&lt;/code&gt;, Turbo cherche le frame directement ou via &lt;code&gt;recurse&lt;/code&gt;. Sans correspondance, il émet &lt;code&gt;turbo:frame-missing&lt;/code&gt;.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;La règle de correspondance, littéralement, est &lt;code class=&quot;highlighter-rouge&quot;&gt;container.querySelector(&quot;turbo-frame#&quot; + CSS.escape(id))&lt;/code&gt;. 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_frame_request_id&lt;/code&gt; de turbo-rails :&lt;/p&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_frame_tag&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_frame_request_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;invoice_detail&quot;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
  …
&lt;span class=&quot;cp&quot;&gt;&amp;lt;%&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Ce fallback n’est pas décoratif. Sur une visite pleine page, l’en-tête est absent et &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_frame_tag nil&lt;/code&gt; produit &lt;code class=&quot;highlighter-rouge&quot;&gt;id=&quot;&quot;&lt;/code&gt;, ce qui casse sans un mot toute navigation ultérieure vers ce frame.&lt;/p&gt;

&lt;p&gt;L’attribut &lt;code class=&quot;highlighter-rouge&quot;&gt;recurse&lt;/code&gt; offre une seconde chance, peu utilisée. Si aucun frame de même id n’est trouvé mais qu’un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-frame src=&quot;/fragment&quot; recurse=&quot;mon-id&quot;&amp;gt;&lt;/code&gt; est présent, Turbo attend son chargement puis va chercher dedans. Le &lt;code class=&quot;highlighter-rouge&quot;&gt;~=&lt;/code&gt; que vous verrez dans le sélecteur interne de Turbo appartient à CSS ; ce n’est pas une syntaxe d’attribut HTML.&lt;/p&gt;

&lt;p&gt;Et si rien ne correspond, le déroulé est le suivant :&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;complete&lt;/code&gt; est posé sur le frame, &lt;strong&gt;avant&lt;/strong&gt; l’événement.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt; est émis sur le frame, annulable, avec &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.response&lt;/code&gt; (une &lt;code class=&quot;highlighter-rouge&quot;&gt;Response&lt;/code&gt; brute) et &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.visit(urlOuResponse, options)&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Si personne n’annule, le frame affiche &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;strong class=&quot;turbo-frame-error&quot;&amp;gt;Content missing&amp;lt;/strong&amp;gt;&lt;/code&gt; et Turbo lève une &lt;code class=&quot;highlighter-rouge&quot;&gt;TurboFrameMissingError&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;L’expiration de session donne un cas classique. La requête part du frame, le serveur redirige vers &lt;code class=&quot;highlighter-rouge&quot;&gt;/login&lt;/code&gt;, et la page de login ne contient évidemment pas votre frame. Plutôt que d’intercepter l’événement, marquez la page de login :&lt;/p&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_page_requires_reload&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;qui pose &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;meta name=&quot;turbo-visit-control&quot; content=&quot;reload&quot;&amp;gt;&lt;/code&gt;. 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt; global.&lt;/p&gt;

&lt;p&gt;Un piège au passage, et il vaut aussi pour &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_refreshes_with&lt;/code&gt; qu’on verra plus loin : ce helper appelle &lt;code class=&quot;highlighter-rouge&quot;&gt;provide :head&lt;/code&gt;. &lt;strong&gt;Il n’écrit rien à l’endroit de l’appel&lt;/strong&gt;, malgré le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= %&amp;gt;&lt;/code&gt;. Sans &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= yield :head %&amp;gt;&lt;/code&gt; dans votre layout, la balise meta ne sort jamais et rien ne se passe. La variante &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_page_requires_reload_tag&lt;/code&gt; rend la balise sur place si vous préférez la placer vous-même.&lt;/p&gt;

&lt;h3 id=&quot;le-layout-et-le-piège-du-layout-statique&quot;&gt;Le layout, et le piège du layout statique&lt;/h3&gt;

&lt;p&gt;turbo-rails installe ceci dans &lt;code class=&quot;highlighter-rouge&quot;&gt;ActionController::Base&lt;/code&gt; :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;layout&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;turbo_rails/frame&quot;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_frame_request?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;etag&lt;/span&gt;   &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:frame&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_frame_request?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Le layout &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_rails/frame&lt;/code&gt; est minimal (juste &lt;code class=&quot;highlighter-rouge&quot;&gt;csrf_meta_tags&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;yield :head&lt;/code&gt;), pas absent, pour que &lt;code class=&quot;highlighter-rouge&quot;&gt;content_for :head&lt;/code&gt; et le CSRF continuent de fonctionner.&lt;/p&gt;

&lt;p&gt;Le piège vient de &lt;code class=&quot;highlighter-rouge&quot;&gt;layout &quot;admin&quot;&lt;/code&gt; dans un contrôleur, qui écrase cette lambda. Les requêtes de frame rendront alors le layout complet. Ça &lt;strong&gt;fonctionne quand même&lt;/strong&gt;, 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt;. Il faut convertir la déclaration en méthode :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;layout&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:layout_for_request&lt;/span&gt;

&lt;span class=&quot;kp&quot;&gt;private&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;layout_for_request&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;turbo_frame_request?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;turbo_rails/frame&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;admin&quot;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote class=&quot;callout callout-piege&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Piège&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;les-attributs-qui-comptent&quot;&gt;Les attributs qui comptent&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Attribut&lt;/th&gt;
      &lt;th&gt;Effet&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Charge cette URL dans le frame. Dans le helper &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_frame_tag&lt;/code&gt;, la valeur passe par &lt;code class=&quot;highlighter-rouge&quot;&gt;url_for&lt;/code&gt;, donc un modèle fonctionne.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;loading=&quot;lazy&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Diffère le premier chargement du &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; jusqu’à l’entrée dans le viewport. Dans un même &lt;code class=&quot;highlighter-rouge&quot;&gt;FrameController&lt;/code&gt;, les changements de &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; suivants chargent immédiatement. Après une restauration Drive, le controller est recréé et ce flag interne repart à zéro : un nouveau &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; hors viewport peut attendre à nouveau.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Cible par défaut des liens et formulaires &lt;strong&gt;descendants&lt;/strong&gt;.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-frame&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Sur un lien, un formulaire ou un &lt;strong&gt;bouton de soumission&lt;/strong&gt;. Écrase &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt;. Le bouton gagne sur le formulaire.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;_top&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Sort du frame : Drive traite la navigation comme une visite pleine page.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;_parent&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Vise le frame ancêtre le plus proche. Ajouté en 8.0.21.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;busy&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Posé par Turbo pendant le chargement, avec &lt;code class=&quot;highlighter-rouge&quot;&gt;aria-busy=&quot;true&quot;&lt;/code&gt;. Utile en CSS.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;complete&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Posé après le render d’un frame correspondant, mais aussi avant &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;. Sur un frame connecté, un changement de &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;reload()&lt;/code&gt; le retire. La propriété JS &lt;code class=&quot;highlighter-rouge&quot;&gt;frame.complete&lt;/code&gt; expose l’état de chargement courant et ne lit pas cet attribut.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;disabled&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Annule le fetch &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; 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.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;autoscroll&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Appelle &lt;code class=&quot;highlighter-rouge&quot;&gt;scrollIntoView()&lt;/code&gt; sur &lt;code class=&quot;highlighter-rouge&quot;&gt;firstElementChild&lt;/code&gt; après le render ; un frame vide ne bouge pas. &lt;code class=&quot;highlighter-rouge&quot;&gt;data-autoscroll-block&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;end&lt;/code&gt; par défaut et &lt;code class=&quot;highlighter-rouge&quot;&gt;data-autoscroll-behavior&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;auto&lt;/code&gt;.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh=&quot;morph&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Fait morpher le contenu chargé par &lt;code class=&quot;highlighter-rouge&quot;&gt;reload()&lt;/code&gt;. Lors d’un page refresh rendu par morph, Turbo conserve un frame &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; compatible, hors d’une zone &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt;, puis appelle automatiquement ce reload.&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Le tableau ne dit pas tout de la façon dont un frame traite une réponse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pour une navigation par link ou &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;, une fois qu’une réponse HTML ordinaire atteint &lt;code class=&quot;highlighter-rouge&quot;&gt;FrameController&lt;/code&gt;, le statut HTTP ne choisit pas le renderer du frame.&lt;/strong&gt; Les callbacks directs &lt;code class=&quot;highlighter-rouge&quot;&gt;requestSucceededWithResponse&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;requestFailedWithResponse&lt;/code&gt; appellent tous deux &lt;code class=&quot;highlighter-rouge&quot;&gt;loadResponse()&lt;/code&gt;. Un 4xx ou 5xx avec du HTML non vide et un frame correspondant peut donc être rendu comme un 2xx. Un Content-Type &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-stream&lt;/code&gt; est intercepté plus tôt par &lt;code class=&quot;highlighter-rouge&quot;&gt;StreamObserver&lt;/code&gt;. Les autres Content-Types non HTML et les bodies vides ne produisent ni frame render, ni &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-render&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-load&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;, même si &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-response&lt;/code&gt; a déjà été émis. Annuler cet événement bloque le chemin de &lt;code class=&quot;highlighter-rouge&quot;&gt;FrameController&lt;/code&gt;, avec l’exception stream décrite dans le tableau. Avec &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt;, Turbo abandonne l’extraction et lance une visite pleine page avec un warning en console ; sans frame correspondant, il suit le chemin &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt; décrit plus haut.&lt;/p&gt;

&lt;p&gt;Une réponse HTML ordinaire à une form submission suit un autre chemin. Si &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-response&lt;/code&gt; n’est pas annulé, un 4xx ou 5xx donne &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.success === false&lt;/code&gt; dans &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:submit-end&lt;/code&gt;, vide le cache de snapshots et appelle &lt;code class=&quot;highlighter-rouge&quot;&gt;loadResponse()&lt;/code&gt; 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.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;interne&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Une précision, parce que « le statut n’est jamais consulté » est légèrement faux. &lt;code class=&quot;highlighter-rouge&quot;&gt;loadResponse&lt;/code&gt; lit bien deux propriétés dérivées du statut, mais pour une seule chose, mettre à jour &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;.&lt;/p&gt;

  &lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fetchResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;redirected&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fetchResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;succeeded&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;fetchResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;isHTML&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;sourceURL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;fetchResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;  &lt;/div&gt;

  &lt;p&gt;(&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/frames/frame_controller.js#L132-L135&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;frame_controller.js#L132-L135&lt;/code&gt;&lt;/a&gt;) Après une navigation par link ou une affectation directe de &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;, le frame pointe déjà vers l’URL demandée : si elle répond avec un 404 HTML, &lt;code class=&quot;highlighter-rouge&quot;&gt;reload()&lt;/code&gt; rejoue cette URL en erreur. Une form submission ne copie pas son action dans &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;. Sur un échec non redirigé, le frame d’origine garde donc son ancien &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;, s’il en avait un, et &lt;code class=&quot;highlighter-rouge&quot;&gt;reload()&lt;/code&gt; peut revenir à cette URL ; sans &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;, il ne charge rien. Après une redirection, &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; prend toujours l’URL finale, même si son statut est un échec.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Une form submission dans un frame n’a pas besoin de rediriger.&lt;/strong&gt; L’erreur &lt;code class=&quot;highlighter-rouge&quot;&gt;Form responses must redirect to another location&lt;/code&gt; n’est déclenchée que pour une submission pleine page en méthode unsafe dont la réponse finale est exactement &lt;code class=&quot;highlighter-rouge&quot;&gt;200 OK&lt;/code&gt; sans redirection. Les submissions &lt;code class=&quot;highlighter-rouge&quot;&gt;GET&lt;/code&gt; n’ont pas cette contrainte, et le guard ne vise pas les autres statuts 2xx. Dans un frame, &lt;code class=&quot;highlighter-rouge&quot;&gt;mustRedirect&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;false&lt;/code&gt; : un &lt;code class=&quot;highlighter-rouge&quot;&gt;200 OK&lt;/code&gt; avec un frame correspondant est valide.&lt;/p&gt;

&lt;h2 id=&quot;turbo-streams&quot;&gt;Turbo Streams&lt;/h2&gt;

&lt;p&gt;Un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; est une enveloppe. Elle porte une &lt;code class=&quot;highlighter-rouge&quot;&gt;action&lt;/code&gt;, une cible, et un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt; :&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;turbo-stream&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;action=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;replace&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;invoice_42&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;template&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;invoice_42&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;…&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/turbo-stream&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Il y a &lt;strong&gt;deux mondes&lt;/strong&gt; derrière ce format, et les confondre explique une bonne partie des erreurs.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;1000&quot; height=&quot;796&quot; src=&quot;/images/posts/turbo/03-stream-vs-broadcast.svg?v=20260809-signature&quot; alt=&quot;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.&quot; /&gt;
  &lt;figcaption&gt;Le format est identique et l&#39;onglet ne peut pas les distinguer. Toute la différence s&#39;est jouée avant, au moment du rendu côté serveur.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Le premier monde est une &lt;strong&gt;réponse HTTP&lt;/strong&gt; : l’utilisateur fait une action, le serveur répond du &lt;code class=&quot;highlighter-rouge&quot;&gt;text/vnd.turbo-stream.html&lt;/code&gt;, l’onglet qui a fait la requête l’applique. C’est synchrone, ça vit dans le contexte de la requête, &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; existe, le HTML est rendu pour cette personne-là.&lt;/p&gt;

&lt;p&gt;Le second est un &lt;strong&gt;broadcast&lt;/strong&gt; : le serveur publie sur un canal Action Cable, tous les onglets abonnés le reçoivent. Les variantes &lt;code class=&quot;highlighter-rouge&quot;&gt;_later_&lt;/code&gt; 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, &lt;code class=&quot;highlighter-rouge&quot;&gt;perform_now&lt;/code&gt; ou l’adapter &lt;code class=&quot;highlighter-rouge&quot;&gt;:inline&lt;/code&gt; peut néanmoins encore voir un &lt;code class=&quot;highlighter-rouge&quot;&gt;Current.*&lt;/code&gt; thread-local ; un job réellement pris par la queue ne l’hérite pas. Un appel qui reçoit déjà &lt;code class=&quot;highlighter-rouge&quot;&gt;html:&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;content:&lt;/code&gt; 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.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-retenir&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;À retenir&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;Le format ne dit rien du contexte. Un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;frames-ou-streams--ce-qui-les-sépare-vraiment&quot;&gt;Frames ou Streams ? Ce qui les sépare vraiment&lt;/h3&gt;

&lt;p&gt;Les deux savent remplacer un morceau de page, et c’est là que la confusion s’installe.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Critère&lt;/th&gt;
      &lt;th&gt;Turbo Frame&lt;/th&gt;
      &lt;th&gt;Turbo Stream&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Qui désigne la cible&lt;/td&gt;
      &lt;td&gt;Le client, avant l’envoi&lt;/td&gt;
      &lt;td&gt;Pour une action ciblée, le serveur, dans la réponse&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Combien de zones&lt;/td&gt;
      &lt;td&gt;Une seule, celle du frame&lt;/td&gt;
      &lt;td&gt;Par &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; : zéro ou une avec &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt;, toutes les correspondances avec &lt;code class=&quot;highlighter-rouge&quot;&gt;targets&lt;/code&gt;. Une réponse peut contenir plusieurs éléments&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;La mutation est-elle une navigation&lt;/td&gt;
      &lt;td&gt;Oui : une URL répond avec le frame attendu&lt;/td&gt;
      &lt;td&gt;Non : le stream décrit une mutation, même si sa requête a une URL&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Historique et bouton retour&lt;/td&gt;
      &lt;td&gt;Optionnels, avec &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-action&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Non&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Lazy loading&lt;/td&gt;
      &lt;td&gt;Oui, &lt;code class=&quot;highlighter-rouge&quot;&gt;loading=&quot;lazy&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Non&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Peut-il partir sans requête&lt;/td&gt;
      &lt;td&gt;Non&lt;/td&gt;
      &lt;td&gt;Oui, c’est le broadcast&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Ce qui se passe si la cible manque&lt;/td&gt;
      &lt;td&gt;Sur le chemin direct, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;, puis « Content missing » par défaut ; &lt;code class=&quot;highlighter-rouge&quot;&gt;recurse&lt;/code&gt; peut encore charger le frame attendu, et &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt; contourne l’extraction&lt;/td&gt;
      &lt;td&gt;Pour une action ciblée, rien du tout, en silence&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Ce que le serveur doit savoir&lt;/td&gt;
      &lt;td&gt;L’identifiant attendu, souvent repris depuis l’en-tête &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo-Frame&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Pour une action ciblée, l’&lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; via &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; ou le sélecteur CSS via &lt;code class=&quot;highlighter-rouge&quot;&gt;targets&lt;/code&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; n’a pas de cible&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;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é. &lt;code class=&quot;highlighter-rouge&quot;&gt;recurse&lt;/code&gt; permet de le trouver dans une seconde réponse, tandis que &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt; abandonne ce contrat pour une visite pleine page. Une action de stream ciblée est plus &lt;strong&gt;couplée&lt;/strong&gt; : le serveur doit savoir adresser ses cibles dans le DOM affiché, sans aucun moyen de vérifier ce contrat. &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; fait exception puisqu’il ne désigne aucune cible.&lt;/p&gt;

&lt;p&gt;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 &lt;code class=&quot;highlighter-rouge&quot;&gt;create.js.erb&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; présents dans les templates de streams, et une faille XSS reste une faille XSS.&lt;/p&gt;

&lt;h3 id=&quot;le-trajet-complet-du-controller-à-la-vue&quot;&gt;Le trajet complet, du controller à la vue&lt;/h3&gt;

&lt;p&gt;Un controller classique suffit :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# app/controllers/invoices_controller.rb&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;create&lt;/span&gt;
  &lt;span class=&quot;vi&quot;&gt;@invoice&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;invoice_params&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;vi&quot;&gt;@invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;save&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;respond_to&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;
      &lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;turbo_stream&lt;/span&gt;                                    &lt;span class=&quot;c1&quot;&gt;# -&amp;gt; create.turbo_stream.erb&lt;/span&gt;
      &lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;html&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;redirect_to&lt;/span&gt; &lt;span class=&quot;vi&quot;&gt;@invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;status: :see_other&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;respond_to&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;
      &lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;turbo_stream&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;render&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;turbo_stream: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;turbo_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;replace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
          &lt;span class=&quot;s2&quot;&gt;&quot;invoice_form&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;partial: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;invoices/form&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;locals: &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;invoice: &lt;/span&gt;&lt;span class=&quot;vi&quot;&gt;@invoice&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;status: :unprocessable_entity&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
      &lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;html&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;render&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;status: :unprocessable_entity&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Le &lt;code class=&quot;highlighter-rouge&quot;&gt;format.turbo_stream&lt;/code&gt; nu rend un template ; utilisez-le quand plusieurs zones changent. Le &lt;code class=&quot;highlighter-rouge&quot;&gt;render turbo_stream:&lt;/code&gt; inline accepte aussi plusieurs tags concaténés, mais il évite surtout un fichier pour les réponses simples.&lt;/p&gt;

&lt;p&gt;Le template est un simple fichier ERB qui appelle le builder :&lt;/p&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;&amp;lt;%# app/views/invoices/create.turbo_stream.erb %&amp;gt;&lt;/span&gt;
&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;prepend&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;invoices&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;vi&quot;&gt;@invoice&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;update&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;invoices_count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;replace&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;invoice_form&quot;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;render&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;invoices/form&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;invoice: &lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;span class=&quot;cp&quot;&gt;&amp;lt;%&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Ces trois lignes condensent presque toute l’API, et chacune cache quelque chose.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.prepend &quot;invoices&quot;, @invoice&lt;/code&gt; ne prend pas de partial : passer un record suffit, le builder appelle &lt;code class=&quot;highlighter-rouge&quot;&gt;to_partial_path&lt;/code&gt; et rend &lt;code class=&quot;highlighter-rouge&quot;&gt;invoices/_invoice.html.erb&lt;/code&gt;. C’est aussi la raison pour laquelle ce partial doit rendre un élément avec &lt;code class=&quot;highlighter-rouge&quot;&gt;id=&quot;&amp;lt;%= dom_id(invoice) %&amp;gt;&quot;&lt;/code&gt;, sans quoi les helpers basés sur le record ne pourront plus le cibler directement.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.update&lt;/code&gt; accepte une chaîne brute comme contenu, ce qui évite un partial d’une ligne pour un compteur.&lt;/p&gt;

&lt;p&gt;La forme à bloc capture ce que vous écrivez dans le &lt;strong&gt;view context courant&lt;/strong&gt;. Dans une réponse HTTP, elle voit donc les ivars du controller, y compris celles posées par les &lt;code class=&quot;highlighter-rouge&quot;&gt;before_action&lt;/code&gt;. Le contexte synthétique décrit plus haut ne concerne que le rendu d’un broadcast.&lt;/p&gt;

&lt;p&gt;Et côté vue appelante, la règle est la même que partout. Ce que le stream vise doit exister avec le bon &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;&amp;lt;%# app/views/invoices/index.html.erb %&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;invoices_count&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;vi&quot;&gt;@invoices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;invoices&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;render&lt;/span&gt; &lt;span class=&quot;vi&quot;&gt;@invoices&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;invoice_form&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;render&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;invoices/form&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;invoice: &lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;&amp;lt;%# app/views/invoices/_invoice.html.erb %&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dom_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;invoice&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;reference&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;button_to&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Supprimer&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;method: :delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;form: &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;data: &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;turbo_confirm: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Sûr ?&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Notez &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-confirm&lt;/code&gt; sur le formulaire du &lt;code class=&quot;highlighter-rouge&quot;&gt;button_to&lt;/code&gt;, successeur direct du &lt;code class=&quot;highlighter-rouge&quot;&gt;data-confirm&lt;/code&gt; de rails-ujs. La différence est que sa fonction est remplaçable : &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.forms.confirm&lt;/code&gt; accepte votre propre fonction, ce qui permet enfin de remplacer la boîte native par votre propre modale sans réécrire le mécanisme.&lt;/p&gt;

&lt;h3 id=&quot;huit-actions-et-morph-nen-est-pas-une&quot;&gt;Huit actions, et &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt; n’en est pas une&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Action&lt;/th&gt;
      &lt;th&gt;Ce qu’elle fait&lt;/th&gt;
      &lt;th&gt;Cible requise&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Ajoute à la fin du contenu de la cible&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;prepend&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Ajoute au début&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;before&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Insère avant la cible&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;after&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Insère après la cible&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Remplace la cible elle-même&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;update&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Remplace le contenu de la cible&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;remove&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Supprime la cible. Pas de &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;oui&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Lance un refresh Turbo sur l’URL courante, sous forme de visite &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt;. Pas de &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;C’est tout. Il n’y a pas d’action &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt;. Le morphing est un &lt;strong&gt;attribut&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;method=&quot;morph&quot;&lt;/code&gt;, et parmi les actions ciblées, &lt;strong&gt;seuls &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;update&lt;/code&gt; le lisent&lt;/strong&gt;. Écrire &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.append(&quot;x&quot;, method: :morph)&lt;/code&gt; produit bien l’attribut dans le HTML, et le handler JavaScript de &lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; l’ignore purement et simplement. &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; lit aussi &lt;code class=&quot;highlighter-rouge&quot;&gt;method&lt;/code&gt;, mais pour une autre raison : choisir le mode de rendu de ce refresh, broadcast par broadcast.&lt;/p&gt;

&lt;p&gt;La référence documente la déduplication de &lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;prepend&lt;/code&gt; : si un enfant direct de la cible porte le même &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; qu’un élément entrant de premier niveau, l’ancien est retiré. &lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; se comporte donc comme un upsert. Depuis 8.0.21, &lt;code class=&quot;highlighter-rouge&quot;&gt;before&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;after&lt;/code&gt; appliquent la même règle aux frères de la cible ; cette extension n’apparaît pas encore dans la référence.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; porte aussi un attribut &lt;code class=&quot;highlighter-rouge&quot;&gt;request-id&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3 id=&quot;target-et-targets-ne-sont-pas-la-même-chose&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;targets&lt;/code&gt; ne sont pas la même chose&lt;/h3&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;turbo_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;replace&lt;/span&gt;     &lt;span class=&quot;s2&quot;&gt;&quot;invoice_42&quot;&lt;/span&gt;        &lt;span class=&quot;c1&quot;&gt;# target  =&amp;gt; getElementById&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;turbo_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;replace_all&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;.invoice-row&quot;&lt;/span&gt;      &lt;span class=&quot;c1&quot;&gt;# targets =&amp;gt; querySelectorAll&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; prend un &lt;strong&gt;identifiant DOM nu&lt;/strong&gt;, pas un sélecteur, et résout au plus un élément. &lt;code class=&quot;highlighter-rouge&quot;&gt;targets&lt;/code&gt; prend un &lt;strong&gt;sélecteur CSS&lt;/strong&gt; et applique l’action à toutes les correspondances. Si les deux sont présents, &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; gagne.&lt;/p&gt;

&lt;p&gt;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 :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;turbo_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;replace&lt;/span&gt;     &lt;span class=&quot;vi&quot;&gt;@invoice&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# target=&quot;invoice_42&quot;    &amp;lt;- dom_id nu&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;turbo_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;replace_all&lt;/span&gt; &lt;span class=&quot;vi&quot;&gt;@invoice&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# targets=&quot;#invoice_42&quot;  &amp;lt;- dom_id avec le #&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;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 : &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.replace_all &quot;invoice-row&quot;&lt;/code&gt; sans le point ou le dièse ne matche rien, et ne dira rien.&lt;/p&gt;

&lt;h3 id=&quot;le-silence-quand-la-cible-nexiste-pas&quot;&gt;Le silence quand la cible n’existe pas&lt;/h3&gt;

&lt;p&gt;Si &lt;code class=&quot;highlighter-rouge&quot;&gt;document.getElementById(target)&lt;/code&gt; retourne &lt;code class=&quot;highlighter-rouge&quot;&gt;null&lt;/code&gt;, le getter retourne un tableau vide, et chaque action itère sur ce tableau vide. &lt;strong&gt;Aucun avertissement, à aucun niveau de log.&lt;/strong&gt; Le stream arrive, il est visible dans l’onglet Réseau, il est visible dans les logs Rails, et il ne se passe rien.&lt;/p&gt;

&lt;p&gt;Les causes classiques sont un &lt;code class=&quot;highlighter-rouge&quot;&gt;dom_id&lt;/code&gt; mal orthographié ou mis au pluriel, une cible qui vit à l’intérieur d’un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt; ou d’une &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; (explicitement non supportés), ou une cible qui est dans un frame &lt;code class=&quot;highlighter-rouge&quot;&gt;loading=&quot;lazy&quot;&lt;/code&gt; pas encore chargé.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-debug&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Debug&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;interne&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Le silence total ne vaut que pour une cible &lt;strong&gt;présente mais introuvable&lt;/strong&gt;. Si l’attribut lui-même manque, &lt;code class=&quot;highlighter-rouge&quot;&gt;get targetElements()&lt;/code&gt; lève &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;target or targets attribute is missing&quot;&lt;/code&gt;, et &lt;code class=&quot;highlighter-rouge&quot;&gt;get performAction()&lt;/code&gt; lève &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;unknown action&quot;&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;action attribute is missing&quot;&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/elements/stream_element.js#L95-L120&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;stream_element.js#L95-L120&lt;/code&gt;&lt;/a&gt;). Ces exceptions sont attrapées par le &lt;code class=&quot;highlighter-rouge&quot;&gt;try/catch&lt;/code&gt; de &lt;code class=&quot;highlighter-rouge&quot;&gt;connectedCallback&lt;/code&gt; et ressortent en &lt;code class=&quot;highlighter-rouge&quot;&gt;console.error&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/elements/stream_element.js#L33-L41&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;stream_element.js#L33-L41&lt;/code&gt;&lt;/a&gt;), donc visibles, mais sans événement et sans rien à l’écran.&lt;/p&gt;

  &lt;p&gt;Regardez d’abord la console : si elle reste vide malgré un stream reçu, cherchez une cible absente ; si elle affiche &lt;code class=&quot;highlighter-rouge&quot;&gt;console.error&lt;/code&gt;, cherchez un attribut manquant ou une action inconnue.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Un autre piège, plus fourbe, concerne les requêtes &lt;strong&gt;GET&lt;/strong&gt;. Turbo n’envoie l’en-tête &lt;code class=&quot;highlighter-rouge&quot;&gt;Accept: text/vnd.turbo-stream.html&lt;/code&gt; que si le lien ou le formulaire porte &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-stream&lt;/code&gt;. Sans cet attribut, la négociation habituelle retombe sur HTML, sauf si l’URL ou les params imposent explicitement le format &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;ajouter-vos-propres-actions&quot;&gt;Ajouter vos propres actions&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Côté Ruby, un load hook :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# config/initializers/turbo.rb&lt;/span&gt;
&lt;span class=&quot;no&quot;&gt;ActiveSupport&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;on_load&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:turbo_streams_tag_builder&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;highlight&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;action&lt;/span&gt;     &lt;span class=&quot;ss&quot;&gt;:highlight&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;highlight_all&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;targets&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;action_all&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:highlight&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;targets&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Côté JavaScript, une entrée dans &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.StreamActions&lt;/code&gt;, où &lt;code class=&quot;highlighter-rouge&quot;&gt;this&lt;/code&gt; est l’élément &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;Turbo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;StreamActions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;highlight&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;function &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;targetElements&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;forEach&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;el&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;el&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;animate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;backgroundColor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;#FFD83F&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;backgroundColor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;transparent&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}],&lt;/span&gt;
               &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1200&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Et depuis un modèle, sans passer par le builder :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;after_update_commit&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;broadcast_action_to&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;plannings&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;action: :highlight&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;target: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;gantt&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;html: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Si l’action n’est pas enregistrée côté JavaScript, l’élément lève &lt;code class=&quot;highlighter-rouge&quot;&gt;unknown action&lt;/code&gt;. Turbo est rarement aussi bruyant.&lt;/p&gt;

&lt;h2 id=&quot;le-statut-http-et-pourquoi-422&quot;&gt;Le statut HTTP, et pourquoi 422&lt;/h2&gt;

&lt;p&gt;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 : &lt;code class=&quot;highlighter-rouge&quot;&gt;FrameController&lt;/code&gt; pour une navigation de frame, &lt;code class=&quot;highlighter-rouge&quot;&gt;Visit&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;FormSubmission&lt;/code&gt; pour une navigation pleine page. La réponse suit ensuite ce chemin :&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Si le &lt;code class=&quot;highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt; commence par &lt;code class=&quot;highlighter-rouge&quot;&gt;text/vnd.turbo-stream.html&lt;/code&gt;&lt;/strong&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;StreamObserver&lt;/code&gt; intercepte la réponse et applique les &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt;, quel que soit le statut.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Sinon, la réponse revient à ce delegate.&lt;/strong&gt; L’en-tête &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo-Frame&lt;/code&gt; sert au serveur pour rendre le bon fragment ; Turbo ne l’utilise pas comme aiguillage au retour.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Sur le chemin pleine page&lt;/strong&gt;, le statut participe alors au choix entre le renderer normal, le renderer d’erreur ou le refus de rendre.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Un formulaire Hotwire qui paraît figé tient souvent à cette ligne de Turbo :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nf&quot;&gt;responseSucceededWithoutRedirect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;statusCode&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;200&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;redirected&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Pour une soumission pleine page non-GET, si vous répondez &lt;code class=&quot;highlighter-rouge&quot;&gt;200&lt;/code&gt; avec du HTML sans redirection, Turbo affiche &lt;code class=&quot;highlighter-rouge&quot;&gt;console.error(&quot;Form responses must redirect to another location&quot;)&lt;/code&gt; et &lt;strong&gt;ne rend rien&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;D’où la convention Rails, que le générateur de scaffold applique déjà :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;render&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;status: :unprocessable_entity&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 422 : la réponse est rendue, l&#39;URL ne bouge pas&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;redirect_to&lt;/span&gt; &lt;span class=&quot;vi&quot;&gt;@invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;status: :see_other&lt;/span&gt;    &lt;span class=&quot;c1&quot;&gt;# 303 : après update et destroy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Le &lt;code class=&quot;highlighter-rouge&quot;&gt;303&lt;/code&gt; 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 :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;303&lt;/strong&gt; remplace toute méthode autre que &lt;code class=&quot;highlighter-rouge&quot;&gt;GET&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;HEAD&lt;/code&gt; par &lt;code class=&quot;highlighter-rouge&quot;&gt;GET&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;301&lt;/strong&gt; et &lt;strong&gt;302&lt;/strong&gt; ne forcent le passage en GET &lt;strong&gt;que pour une requête HTTP POST&lt;/strong&gt;. Une vraie requête HTTP &lt;code class=&quot;highlighter-rouge&quot;&gt;DELETE&lt;/code&gt; redirigée en 302 serait rejouée en &lt;code class=&quot;highlighter-rouge&quot;&gt;DELETE&lt;/code&gt; sur l’URL cible.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;307&lt;/strong&gt; et &lt;strong&gt;308&lt;/strong&gt; préservent toujours la méthode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mais &lt;strong&gt;turbo-rails 2.0.23 n’envoie pas ces méthodes comme telles&lt;/strong&gt;. Son listener &lt;code class=&quot;highlighter-rouge&quot;&gt;encodeMethodIntoRequestBody&lt;/code&gt; transforme toute méthode non-GET en requête HTTP &lt;code class=&quot;highlighter-rouge&quot;&gt;POST&lt;/code&gt; et place la méthode logique dans &lt;code class=&quot;highlighter-rouge&quot;&gt;_method&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo-rails/blob/v2.0.23/app/javascript/turbo/fetch_requests.js#L1-L18&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;fetch_requests.js#L1-L18&lt;/code&gt;&lt;/a&gt;). Cela vaut pour &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-method=&quot;delete&quot;&lt;/code&gt; comme pour &lt;code class=&quot;highlighter-rouge&quot;&gt;button_to method: :delete&lt;/code&gt;. Dans cette configuration, un 302 dégrade donc bien le POST réseau en GET ; il ne rejoue pas &lt;code class=&quot;highlighter-rouge&quot;&gt;DELETE /invoices&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Gardez tout de même &lt;code class=&quot;highlighter-rouge&quot;&gt;status: :see_other&lt;/code&gt;. Le &lt;code class=&quot;highlighter-rouge&quot;&gt;303&lt;/code&gt; documente l’intention, fonctionne avec Turbo sans l’adaptateur Rails et évite que le comportement dépende de l’encodage &lt;code class=&quot;highlighter-rouge&quot;&gt;_method&lt;/code&gt; ou d’un autre client HTTP. C’est une convention robuste, pas le correctif d’un &lt;code class=&quot;highlighter-rouge&quot;&gt;DELETE&lt;/code&gt; rejoué par turbo-rails.&lt;/p&gt;

&lt;p&gt;Une réponse turbo-stream court-circuite le choix du renderer. &lt;code class=&quot;highlighter-rouge&quot;&gt;render turbo_stream: …, status: :unprocessable_entity&lt;/code&gt; s’applique donc parfaitement. Le statut reste observable : sur une soumission, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:submit-end.detail.success&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;false&lt;/code&gt; pour un 422 et &lt;code class=&quot;highlighter-rouge&quot;&gt;true&lt;/code&gt; pour un 2xx. Il conserve aussi son sens pour les tests et les clients non-Turbo.&lt;/p&gt;

&lt;p&gt;Petit détail amusant, le test porte sur &lt;code class=&quot;highlighter-rouge&quot;&gt;statusCode == 200&lt;/code&gt; exactement. Un &lt;code class=&quot;highlighter-rouge&quot;&gt;201 Created&lt;/code&gt; passe à travers le garde-fou et, si la réponse contient un body HTML non vide, poursuit vers une visite. Sans ce body, &lt;code class=&quot;highlighter-rouge&quot;&gt;Navigator&lt;/code&gt; n’en propose aucune.&lt;/p&gt;

&lt;h3 id=&quot;4xx-et-5xx--deux-chemins-de-rendu-pas-un&quot;&gt;4xx et 5xx : deux chemins de rendu, pas un&lt;/h3&gt;

&lt;p&gt;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. &lt;strong&gt;Ce n’est vrai que pour les soumissions de formulaire.&lt;/strong&gt;&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Ce que vous avez fait&lt;/th&gt;
      &lt;th&gt;2xx HTML&lt;/th&gt;
      &lt;th&gt;4xx HTML&lt;/th&gt;
      &lt;th&gt;5xx HTML&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Soumettre un formulaire pleine page&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;PageRenderer&lt;/code&gt;, sauf le 200 non redirigé d’une méthode non-GET, qui est refusé&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;PageRenderer&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;ErrorRenderer&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cliquer un lien, ou &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.visit()&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;PageRenderer&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;ErrorRenderer&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;ErrorRenderer&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Le tableau suppose une réponse HTML exploitable. Pour une soumission réussie, &lt;code class=&quot;highlighter-rouge&quot;&gt;Navigator&lt;/code&gt; ne propose la visite que si &lt;code class=&quot;highlighter-rouge&quot;&gt;responseHTML&lt;/code&gt; contient quelque chose ; une réponse vide ou non-HTML ne passe donc pas dans &lt;code class=&quot;highlighter-rouge&quot;&gt;PageRenderer&lt;/code&gt;. Sur une visite, une réponse non-HTML prend le chemin d’erreur.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;interne&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Sur une visite, le test est binaire : &lt;code class=&quot;highlighter-rouge&quot;&gt;if (isSuccessful(statusCode) &amp;amp;&amp;amp; responseHTML != null)&lt;/code&gt; rend normalement, &lt;code class=&quot;highlighter-rouge&quot;&gt;else&lt;/code&gt; part sur &lt;code class=&quot;highlighter-rouge&quot;&gt;this.view.renderError(...)&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L203-L223&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;visit.js#L203-L223&lt;/code&gt;&lt;/a&gt;), avec &lt;code class=&quot;highlighter-rouge&quot;&gt;isSuccessful&lt;/code&gt; défini comme &lt;code class=&quot;highlighter-rouge&quot;&gt;statusCode &amp;gt;= 200 &amp;amp;&amp;amp; statusCode &amp;lt; 300&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L417-L419&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;visit.js#L417-L419&lt;/code&gt;&lt;/a&gt;). La distinction 4xx/5xx n’existe que dans &lt;code class=&quot;highlighter-rouge&quot;&gt;Navigator#formSubmissionFailedWithResponse&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/navigator.js#L92-L107&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;navigator.js#L92-L107&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ça mérite mieux qu’une note de bas de page, parce que &lt;code class=&quot;highlighter-rouge&quot;&gt;ErrorRenderer&lt;/code&gt; est nettement plus brutal que &lt;code class=&quot;highlighter-rouge&quot;&gt;PageRenderer&lt;/code&gt; :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Il fait un &lt;code class=&quot;highlighter-rouge&quot;&gt;replaceChild&lt;/code&gt; sur le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; &lt;strong&gt;entier&lt;/strong&gt;, au lieu de le fusionner.&lt;/li&gt;
  &lt;li&gt;Il parcourt &lt;strong&gt;tous&lt;/strong&gt; les &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; du document, y compris ceux du &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/error_renderer.js#L36-L38&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;error_renderer.js#L36-L38&lt;/code&gt;&lt;/a&gt;), puis réactive ceux qui n’ont pas &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/util.js#L1-L15&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;util.js#L1-L15&lt;/code&gt;&lt;/a&gt;).&lt;/li&gt;
  &lt;li&gt;Il ne fait pas tourner Bardo, la machinerie qui transplante les éléments persistants. &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; n’est pas honoré sur une page d’erreur.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Et symétriquement, comme un 4xx de formulaire passe par &lt;code class=&quot;highlighter-rouge&quot;&gt;PageRenderer&lt;/code&gt;, il est soumis à &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldRender&lt;/code&gt; : une divergence &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-track=&quot;reload&quot;&lt;/code&gt; sur votre page d’erreur transformera l’affichage des erreurs de validation en rechargement complet du navigateur.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-piege&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Piège&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;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 &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; qui n’ont pas &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;le-morphing&quot;&gt;Le morphing&lt;/h2&gt;

&lt;p&gt;Turbo 8 a introduit les &lt;em&gt;page refreshes&lt;/em&gt; avec morphing. L’idée est de comparer l’ancien arbre et le nouveau plutôt que de remplacer le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;On l’active dans le layout :&lt;/p&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_refreshes_with&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;method: :morph&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;scroll: :preserve&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Et voici le premier piège, responsable d’une bonne part des « le morphing ne marche pas chez moi » : &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_refreshes_with&lt;/code&gt; appelle &lt;code class=&quot;highlighter-rouge&quot;&gt;provide :head&lt;/code&gt;. &lt;strong&gt;Il n’écrit rien à l’endroit où vous l’appelez.&lt;/strong&gt; Sans &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= yield :head %&amp;gt;&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;:replace&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;:morph&lt;/code&gt; pour &lt;code class=&quot;highlighter-rouge&quot;&gt;method:&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;:reset&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;:preserve&lt;/code&gt; pour &lt;code class=&quot;highlighter-rouge&quot;&gt;scroll:&lt;/code&gt;, tout le reste lève une &lt;code class=&quot;highlighter-rouge&quot;&gt;ArgumentError&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;ce-qui-déclenche-réellement-un-morph&quot;&gt;Ce qui déclenche réellement un morph&lt;/h3&gt;

&lt;p&gt;Un morph de page n’a lieu que pour un &lt;strong&gt;page refresh&lt;/strong&gt;. Quand &lt;code class=&quot;highlighter-rouge&quot;&gt;PageView&lt;/code&gt; reçoit une Visit, le test exact tient en deux clauses : &lt;strong&gt;même &lt;code class=&quot;highlighter-rouge&quot;&gt;pathname&lt;/code&gt;&lt;/strong&gt; (la query string et le fragment ne comptent pas) &lt;strong&gt;et &lt;code class=&quot;highlighter-rouge&quot;&gt;action === &quot;replace&quot;&lt;/code&gt;&lt;/strong&gt;. Sans Visit, &lt;code class=&quot;highlighter-rouge&quot;&gt;isPageRefresh()&lt;/code&gt; renvoie directement &lt;code class=&quot;highlighter-rouge&quot;&gt;true&lt;/code&gt; ; une réponse 4xx à une full-page form submission passe notamment par ce chemin et peut morpher si la méthode effective vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sans action explicite, une soumission non-GET ne prend &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; que si la réponse a redirigé vers l’URL exacte de la page qui portait le formulaire. Turbo compare l’URL finale à &lt;code class=&quot;highlighter-rouge&quot;&gt;history.location&lt;/code&gt;, pas à l’action du formulaire (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/navigator.js#L158-L166&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;navigator.js#L158-L166&lt;/code&gt;&lt;/a&gt;). Depuis &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices/42&lt;/code&gt;, un &lt;code class=&quot;highlighter-rouge&quot;&gt;PATCH /invoices/42&lt;/code&gt; qui redirige vers &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices/42&lt;/code&gt; morphe. Depuis &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices&lt;/code&gt;, un &lt;code class=&quot;highlighter-rouge&quot;&gt;POST /invoices&lt;/code&gt; qui redirige vers &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices/42&lt;/code&gt; prend &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt;. Un formulaire GET prend lui aussi &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt;, sauf si sa réponse est redirigée vers l’URL exacte de la page de départ.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-action=&quot;replace&quot;&lt;/code&gt; sur le formulaire ou son bouton force toutefois cette action. Un formulaire GET qui passe de &lt;code class=&quot;highlighter-rouge&quot;&gt;/search?q=old&lt;/code&gt; à &lt;code class=&quot;highlighter-rouge&quot;&gt;/search?q=new&lt;/code&gt; peut donc morpher : seul le &lt;code class=&quot;highlighter-rouge&quot;&gt;pathname&lt;/code&gt; doit rester identique. La même règle vaut pour un lien &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-action=&quot;replace&quot;&lt;/code&gt; de &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices?page=2&lt;/code&gt; vers &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices?page=3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Deux déclencheurs courants sont un formulaire qui redirige vers la page dont il est parti et un broadcast &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream action=&quot;refresh&quot;&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;ce-que-le-morphing-change-ligne-à-ligne&quot;&gt;Ce que le morphing change, ligne à ligne&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Ce qui se passe&lt;/th&gt;
      &lt;th&gt;Rendu classique&lt;/th&gt;
      &lt;th&gt;Morph&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;quand Turbo s’apprête à cacher le snapshot&lt;/td&gt;
      &lt;td&gt;idem&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Snapshot mis en cache&lt;/td&gt;
      &lt;td&gt;selon la politique de la visite&lt;/td&gt;
      &lt;td&gt;idem&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;remplacé entièrement&lt;/td&gt;
      &lt;td&gt;comparé nœud à nœud par idiomorph&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; / &lt;code class=&quot;highlighter-rouge&quot;&gt;connect()&lt;/code&gt; Stimulus&lt;/td&gt;
      &lt;td&gt;sur tout&lt;/td&gt;
      &lt;td&gt;sur les nœuds ajoutés, supprimés ou déplacés ; même &lt;code class=&quot;highlighter-rouge&quot;&gt;moveBefore&lt;/code&gt; produit les mutations que Stimulus interprète comme une déconnexion puis une reconnexion&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; inline déjà présents&lt;/td&gt;
      &lt;td&gt;réexécutés sauf avec &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt; jamais&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; réellement nouveaux et éligibles à l’évaluation&lt;/td&gt;
      &lt;td&gt;exécutés&lt;/td&gt;
      &lt;td&gt;exécutés ; &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt; les laisse inertes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;transplanté par Bardo, &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; &lt;strong&gt;requis&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;nœud courant ignoré s’il porte déjà l’attribut, &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; non requis, Bardo n’est pas appelé&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Scroll après un page refresh&lt;/td&gt;
      &lt;td&gt;préservé avec &lt;code class=&quot;highlighter-rouge&quot;&gt;scroll: :preserve&lt;/code&gt;, sinon remis à zéro&lt;/td&gt;
      &lt;td&gt;idem&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Focus et sélection de texte&lt;/td&gt;
      &lt;td&gt;perdus par défaut ; restaurés dans un élément permanent correspondant&lt;/td&gt;
      &lt;td&gt;conservés si le même nœud DOM reste en place ; après remplacement, restauration limitée aux &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;input&amp;gt;&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; portant un &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;autofocus&lt;/code&gt; lors d’un page refresh&lt;/td&gt;
      &lt;td&gt;honoré&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;non&quot;&gt;✕&lt;/span&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;MorphingPageRenderer.shouldAutofocus&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;false&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:render&lt;/code&gt; puis &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:load&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;émis&lt;/td&gt;
      &lt;td&gt;émis&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;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 &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldCacheSnapshot: false&lt;/code&gt; : pas de snapshot, donc pas de &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; passe aussi ce flag, avec une exception liée au LRU détaillée ci-dessous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:load&lt;/code&gt; se déclenche bien après un morph.&lt;/strong&gt; Un refresh est une vraie visite. Ce qui ne rejoue pas, ce sont les &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; inline. C’est asserté par la suite de tests du dépôt lui-même (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/tests/functional/page_refresh_tests.js#L33-L42&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;page_refresh_tests.js#L33-L42&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; n’est pas une garantie du morphing.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;interne&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; force &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldCacheSnapshot: false&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/session.js#L108-L117&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;session.js#L108-L117&lt;/code&gt;&lt;/a&gt;). En l’absence d’un snapshot déjà caché pour l’URL courante, un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream action=&quot;refresh&quot;&amp;gt;&lt;/code&gt; n’émet donc pas &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt;. Mais &lt;code class=&quot;highlighter-rouge&quot;&gt;BrowserAdapter#visitStarted()&lt;/code&gt; appelle d’abord &lt;code class=&quot;highlighter-rouge&quot;&gt;loadCachedSnapshot()&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/native/browser_adapter.js#L21-L26&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;browser_adapter.js#L21-L26&lt;/code&gt;&lt;/a&gt;). Si le LRU contient déjà un snapshot previewable pour cette URL &lt;strong&gt;et que le document courant est cacheable&lt;/strong&gt;, ce chemin rappelle &lt;code class=&quot;highlighter-rouge&quot;&gt;cacheSnapshot()&lt;/code&gt; sans vérifier le flag et émet l’événement (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/visit.js#L245-L263&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;visit.js#L245-L263&lt;/code&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/page_view.js#L43-L50&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;page_view.js#L43-L50&lt;/code&gt;&lt;/a&gt;). Après une soumission réussie, &lt;code class=&quot;highlighter-rouge&quot;&gt;Navigator&lt;/code&gt; passe &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldCacheSnapshot: formSubmission.isSafe&lt;/code&gt; et vide auparavant le cache pour une méthode non-GET (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/navigator.js#L71-L87&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;navigator.js#L71-L87&lt;/code&gt;&lt;/a&gt;) : un POST qui redirige sur la même URL peut donc morpher &lt;strong&gt;sans&lt;/strong&gt; émettre l’événement. Une visite &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; ordinaire ou un formulaire GET cacheable conserve au contraire le snapshot et l’émet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ne branchez donc pas un teardown nécessaire au DOM courant sur &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; en supposant qu’il précède tous les morphs. Gardez cet événement pour nettoyer &lt;strong&gt;le snapshot quand il y en a un&lt;/strong&gt;, et utilisez les événements de morph ou le cycle de vie du composant pour protéger le document courant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Un script nouveau et éligible à l’évaluation peut s’exécuter sous morph.&lt;/strong&gt; idiomorph matche l’ancien &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; avec le nouveau par nom de balise, puis synchronise leurs nœuds texte par &lt;code class=&quot;highlighter-rouge&quot;&gt;oldNode.nodeValue = newNode.nodeValue&lt;/code&gt;. Modifier le nœud texte d’un script déjà exécuté ne le réexécute pas.&lt;/p&gt;

&lt;p&gt;Un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; issu de &lt;code class=&quot;highlighter-rouge&quot;&gt;DOMParser&lt;/code&gt; ou d’un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt; est inerte et ne s’exécute pas par simple insertion. Turbo le remplace donc par un élément créé avec &lt;code class=&quot;highlighter-rouge&quot;&gt;document.createElement(&quot;script&quot;)&lt;/code&gt; avant le rendu (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/util.js#L1-L15&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;util.js#L1-L15&lt;/code&gt;&lt;/a&gt;). Cette activation a aussi lieu sur le chemin morph, avant qu’idiomorph ne compare les arbres (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/drive/page_renderer.js#L174-L190&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;page_renderer.js#L174-L190&lt;/code&gt;&lt;/a&gt;). 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt;, l’élément activé entre dans le DOM et s’exécute.&lt;/p&gt;

&lt;h3 id=&quot;idiomorph-décide-par-les-identifiants&quot;&gt;idiomorph décide par les identifiants&lt;/h3&gt;

&lt;p&gt;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à.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;1000&quot; height=&quot;788&quot; src=&quot;/images/posts/turbo/04-idiomorph.svg?v=20260809-signature&quot; alt=&quot;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&#39;un id compte, et la bifurcation moveBefore contre insertBefore.&quot; /&gt;
  &lt;figcaption&gt;Le contenu et l&#39;ordre visuel sont identiques dans les deux colonnes ; le nouveau HTML ne l&#39;est pas. Ce qui change, c&#39;est ce qui survit à l&#39;opération.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Un &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; n’est considéré comme stable que si les trois conditions suivantes sont réunies : il existe dans les deux arbres, &lt;strong&gt;le nom de balise est identique&lt;/strong&gt;, et il &lt;strong&gt;n’est dupliqué dans aucun des deux&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Cette troisième condition mérite d’être encadrée. Un seul &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; dupliqué dans l’une des deux racines morphées fait sortir cet &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; de l’ensemble persistant, &lt;strong&gt;dans les deux copies&lt;/strong&gt;. Pour un page morph, ces racines sont les deux &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; ; pour un stream morph, la recherche reste limitée à la cible et au contenu entrant. Un doublon dans le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sans identifiants stables, le matching se fait par position. Ajouter une ligne en tête de liste réécrit le texte de &lt;strong&gt;toutes&lt;/strong&gt; 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.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-retenir&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;À retenir&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;Donnez un &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; stable et unique à chaque élément de liste&lt;/strong&gt;, ne le réutilisez jamais, ne changez jamais la balise associée. &lt;code class=&quot;highlighter-rouge&quot;&gt;dom_id(record)&lt;/code&gt; respecte ces trois règles ; sans elles, le morphing d’une liste devient imprévisible.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;les-déplacements-de-nœuds-et-pourquoi-safari-diffère&quot;&gt;Les déplacements de nœuds, et pourquoi Safari diffère&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Lorsqu’idiomorph réutilise ailleurs un nœud à &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;div&amp;gt;&lt;/code&gt; caché après &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. Dans les deux cas, idiomorph utilise &lt;code class=&quot;highlighter-rouge&quot;&gt;moveBefore()&lt;/code&gt; lorsqu’il est disponible, avec &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore()&lt;/code&gt; en fallback.&lt;/p&gt;

&lt;p&gt;Le choix entre les deux change le comportement observable :&lt;/p&gt;

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

&lt;p&gt;Et voici la partie que j’ai vue mal comprise plus d’une fois. On présente volontiers &lt;code class=&quot;highlighter-rouge&quot;&gt;moveBefore&lt;/code&gt; comme « largement disponible en 2026 ». Ça n’est vrai qu’en excluant Apple.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-observe&quot;&gt;vérifié&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;D’après &lt;code class=&quot;highlighter-rouge&quot;&gt;@mdn/browser-compat-data&lt;/code&gt; (relevé du 6 août 2026) : Chromium 133+, Firefox 144+, Opera 118, Samsung Internet 29. &lt;strong&gt;Safari, Safari iOS et l’iOS WebView sont tous les trois à &lt;code class=&quot;highlighter-rouge&quot;&gt;version_added: false&lt;/code&gt;&lt;/strong&gt;, le &lt;a href=&quot;https://bugs.webkit.org/show_bug.cgi?id=281223&quot;&gt;bug WebKit 281223&lt;/a&gt; étant toujours ouvert.&lt;/p&gt;

  &lt;p&gt;Chrome et Firefox distribués aujourd’hui sur iPhone utilisent eux aussi WebKit, ils prennent donc ce fallback. Apple &lt;a href=&quot;https://developer.apple.com/support/alternative-browser-engines/&quot;&gt;autorise toutefois des moteurs alternatifs dans l’Union européenne&lt;/a&gt; : 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3 id=&quot;la-valeur-des-champs-nest-pas-protégée&quot;&gt;La valeur des champs n’est pas protégée&lt;/h3&gt;

&lt;p&gt;idiomorph ne synchronise pas seulement les attributs, il écrit aussi les &lt;strong&gt;valeurs courantes des propriétés DOM&lt;/strong&gt; des contrôles de formulaire. Le code est explicite :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;newElement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;hasAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ignoreAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;oldElement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;remove&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ctx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;oldElement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;           &lt;span class=&quot;c1&quot;&gt;// ce que l&#39;utilisateur avait tapé&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;oldElement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;removeAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Le HTML serveur peut contenir un attribut &lt;code class=&quot;highlighter-rouge&quot;&gt;value&lt;/code&gt;, 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;checked&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;disabled&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;option selected&amp;gt;&lt;/code&gt; et le contenu des &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-piege&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Piège&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;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 &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.visit(location.href, { action: &quot;replace&quot; })&lt;/code&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;idiomorph possède une option &lt;code class=&quot;highlighter-rouge&quot;&gt;ignoreActiveValue&lt;/code&gt; qui exclut &lt;code class=&quot;highlighter-rouge&quot;&gt;document.activeElement&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;morphElements&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;morphChildren&lt;/code&gt;, qui transmettent cette option à idiomorph ; un renderer custom peut donc l’utiliser.&lt;/p&gt;

&lt;p&gt;Sans renderer custom, il reste à protéger le champ vous-même. Le levier le plus direct est &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt;, qui fait sauter le morph sur l’élément ; les événements &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-morph-element&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-morph-attribute&lt;/code&gt; 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 :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;focusin&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;field&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;HTMLInputElement&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;HTMLTextAreaElement&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;HTMLSelectElement&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;HTMLElement&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;isContentEditable&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;closest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;[contenteditable]&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;

  &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;field&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;hasAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;data-turbo-permanent&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toggleAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;data-turbo-permanent&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;unprotect&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;relatedTarget&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;relatedTarget&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Node&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;relatedTarget&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;

      &lt;span class=&quot;nx&quot;&gt;field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;removeEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;focusout&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;unprotect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toggleAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;data-turbo-permanent&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;nx&quot;&gt;field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;focusout&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;unprotect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;input&amp;gt;&lt;/code&gt; ou un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; &lt;strong&gt;qui porte un &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;&lt;/strong&gt;. Il n’offre aucune garantie équivalente pour un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;select&amp;gt;&lt;/code&gt;, un &lt;code class=&quot;highlighter-rouge&quot;&gt;contenteditable&lt;/code&gt; ou un champ sans &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Lors d’un page morph, l’&lt;code class=&quot;highlighter-rouge&quot;&gt;autofocus&lt;/code&gt; n’est pas honoré : &lt;code class=&quot;highlighter-rouge&quot;&gt;MorphingPageRenderer&lt;/code&gt; déclare &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldAutofocus&lt;/code&gt; à &lt;code class=&quot;highlighter-rouge&quot;&gt;false&lt;/code&gt;. Un frame morph suit un autre chemin ; &lt;code class=&quot;highlighter-rouge&quot;&gt;MorphingFrameRenderer&lt;/code&gt; hérite de &lt;code class=&quot;highlighter-rouge&quot;&gt;FrameRenderer&lt;/code&gt;, qui applique bien l’&lt;code class=&quot;highlighter-rouge&quot;&gt;autofocus&lt;/code&gt; après le rendu.&lt;/p&gt;

&lt;h3 id=&quot;la-boîte-à-outils-de-protection&quot;&gt;La boîte à outils de protection&lt;/h3&gt;

&lt;p&gt;Du plus fin au plus brutal :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// 1. Protéger un attribut précis&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo:before-morph-attribute&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;attributeName&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;detail&lt;/span&gt;            &lt;span class=&quot;c1&quot;&gt;// + mutationType: &quot;update&quot; | &quot;remove&quot;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;attributeName&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;preventDefault&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// 2. Protéger un sous-arbre entier&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo:before-morph-element&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;matches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;.widget-tiers&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;preventDefault&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// 3. Réinitialiser après coup (turbo:morph-element)&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo:morph-element&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;cm&quot;&gt;/* … */&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;&amp;lt;%# 4. Le gel complet %&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;map&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-turbo-permanent&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;…&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;La documentation ne dit rien de ce qui suit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; n’a pas la même sémantique selon le mode de rendu.&lt;/strong&gt; En rendu classique, le sélecteur est &lt;code class=&quot;highlighter-rouge&quot;&gt;[id][data-turbo-permanent]&lt;/code&gt; : &lt;strong&gt;l’&lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; est obligatoire&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;interne&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Sous morph, &lt;code class=&quot;highlighter-rouge&quot;&gt;beforeNodeMorphed&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;beforeNodeAdded&lt;/code&gt; s’occupent de &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt;, avec des critères différents.&lt;/p&gt;

  &lt;p&gt;Le guard « ne me touche pas » (&lt;code class=&quot;highlighter-rouge&quot;&gt;beforeNodeMorphed&lt;/code&gt;) teste &lt;strong&gt;l’attribut du nœud courant&lt;/strong&gt; : pas besoin d’&lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;, le nœud est laissé tel quel s’il porte déjà &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt;. Si seul le HTML entrant ajoute l’attribut, ce premier morph a lieu ; les suivants seront gelés. Le guard de &lt;code class=&quot;highlighter-rouge&quot;&gt;beforeNodeAdded&lt;/code&gt;, lui, teste bien l’&lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;, mais avec la polarité inverse : &lt;code class=&quot;highlighter-rouge&quot;&gt;beforeNodeAdded = (node) =&amp;gt; !(node.id &amp;amp;&amp;amp; node.hasAttribute(&quot;data-turbo-permanent&quot;) &amp;amp;&amp;amp; document.getElementById(node.id))&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/morphing.js#L61-L63&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;morphing.js#L61-L63&lt;/code&gt;&lt;/a&gt;). Un élément permanent entrant &lt;strong&gt;qui porte un &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; déjà présent dans le document&lt;/strong&gt; est donc refusé : c’est l’existant qui gagne. Sans &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;, il est inséré normalement.&lt;/p&gt;

  &lt;p&gt;Autre chose, qui explique bien des surprises. &lt;code class=&quot;highlighter-rouge&quot;&gt;MorphingPageRenderer&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;MorphingFrameRenderer&lt;/code&gt; réduisent tous les deux Bardo à &lt;code class=&quot;highlighter-rouge&quot;&gt;async preservingPermanentElements(callback) { return await callback() }&lt;/code&gt;. Sous morph, la machinerie de transplantation de Drive &lt;strong&gt;ne tourne pas du tout&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Sous morph, un nœud courant qui porte &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; gèle son sous-arbre complètement.&lt;/strong&gt; Les mises à jour serveur légitimes à l’intérieur n’arriveront jamais. C’est l’outil de dernier recours, pas le réflexe.&lt;/p&gt;

&lt;p&gt;Enfin, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-morph-element&lt;/code&gt; est aussi émis pour les nœuds sur le point d’être &lt;strong&gt;supprimés&lt;/strong&gt;, et dans ce cas &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.newElement&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;undefined&lt;/code&gt;. Un listener qui écrit &lt;code class=&quot;highlighter-rouge&quot;&gt;event.detail.newElement.matches(…)&lt;/code&gt; lèvera une &lt;code class=&quot;highlighter-rouge&quot;&gt;TypeError&lt;/code&gt; un jour ou l’autre.&lt;/p&gt;

&lt;h2 id=&quot;les-broadcasts&quot;&gt;Les broadcasts&lt;/h2&gt;

&lt;p&gt;Les macros de broadcast tiennent en une ligne. Leurs erreurs, elles, n’apparaissent souvent qu’en production.&lt;/p&gt;

&lt;h3 id=&quot;quatre-macros-et-une-asymétrie&quot;&gt;Quatre macros, et une asymétrie&lt;/h3&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Card&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;broadcasts_refreshes_to&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:board&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# 1 seul after_commit, tout part vers board&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Macro&lt;/th&gt;
      &lt;th&gt;Création&lt;/th&gt;
      &lt;th&gt;Mise à jour&lt;/th&gt;
      &lt;th&gt;Suppression&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_to :board&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; vers &lt;code class=&quot;highlighter-rouge&quot;&gt;board&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; vers &lt;code class=&quot;highlighter-rouge&quot;&gt;board&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;remove&lt;/code&gt; vers &lt;code class=&quot;highlighter-rouge&quot;&gt;board&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; vers &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;cards&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; vers &lt;strong&gt;le flux GID du record&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;remove&lt;/code&gt; vers &lt;strong&gt;le flux GID&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; vers &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;cards&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; vers &lt;strong&gt;le flux GID&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; vers &lt;strong&gt;le flux GID&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes_to :board&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; vers &lt;code class=&quot;highlighter-rouge&quot;&gt;board&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; vers &lt;code class=&quot;highlighter-rouge&quot;&gt;board&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; vers &lt;code class=&quot;highlighter-rouge&quot;&gt;board&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Lisez la deuxième et la troisième ligne deux fois. &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes&lt;/code&gt; envoient les &lt;strong&gt;créations&lt;/strong&gt; vers le flux de la collection, mais les &lt;strong&gt;mises à jour et les suppressions vers le flux propre du record&lt;/strong&gt;. Une page qui ne fait que &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream_from &quot;cards&quot;&lt;/code&gt; verra apparaître les nouvelles cartes et ne verra &lt;strong&gt;jamais&lt;/strong&gt; les modifications ni les suppressions.&lt;/p&gt;

&lt;p&gt;Le code est intentionnel, mais &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream_from Card&lt;/code&gt; ne corrige pas l’asymétrie : la classe donne le stream &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;Card&quot;&lt;/code&gt;, pas &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;cards&quot;&lt;/code&gt;. Pour qu’un index reçoive les trois opérations, utilisez une macro &lt;code class=&quot;highlighter-rouge&quot;&gt;_to&lt;/code&gt; et abonnez la page au même stream. Avec les macros sans &lt;code class=&quot;highlighter-rouge&quot;&gt;_to&lt;/code&gt;, il faudrait écouter le stream de collection pour les créations et celui de chaque record pour les mises à jour et suppressions.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-retenir&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;À retenir&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;Deux macros gardent création, mise à jour et suppression sur un même flux : &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_to :board&lt;/code&gt;&lt;/strong&gt; pour diffuser &lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; / &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; / &lt;code class=&quot;highlighter-rouge&quot;&gt;remove&lt;/code&gt;, et &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes_to :board&lt;/code&gt;&lt;/strong&gt; pour ne diffuser que &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt;. Les variantes sans &lt;code class=&quot;highlighter-rouge&quot;&gt;_to&lt;/code&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Autre asymétrie utile à connaître. Pour les trois premières macros, les suppressions sont &lt;strong&gt;synchrones&lt;/strong&gt; 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 &lt;strong&gt;pas soumise au debounce&lt;/strong&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes_to&lt;/code&gt; échappe à ça justement parce qu’elle n’installe qu’un seul &lt;code class=&quot;highlighter-rouge&quot;&gt;after_commit&lt;/code&gt; : tout y est asynchrone et passe par le debounce, y compris le &lt;code class=&quot;highlighter-rouge&quot;&gt;destroy&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;le-chemin-complet-dun-refresh&quot;&gt;Le chemin complet d’un refresh&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Chaque &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch&lt;/code&gt; de Turbo génère un UUID, l’ajoute à un ensemble borné à 20 entrées, et l’envoie en &lt;code class=&quot;highlighter-rouge&quot;&gt;X-Turbo-Request-Id&lt;/code&gt;. Côté Rails, un &lt;code class=&quot;highlighter-rouge&quot;&gt;around_action&lt;/code&gt; le recopie dans &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.current_request_id&lt;/code&gt;. Le broadcast le réémet dans l’attribut &lt;code class=&quot;highlighter-rouge&quot;&gt;request-id&lt;/code&gt;. À la réception, &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; ignore le refresh si l’identifiant est dans son ensemble local.&lt;/p&gt;

&lt;p&gt;La raison est bonne. L’onglet qui a écrit a &lt;strong&gt;déjà&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Cette protection disparaît, ou agit au mauvais moment, dans les cas suivants :&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Hors requête&lt;/strong&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.current_request_id&lt;/code&gt; est nul : c’est un &lt;code class=&quot;highlighter-rouge&quot;&gt;thread_mattr_accessor&lt;/code&gt; alimenté par un &lt;code class=&quot;highlighter-rouge&quot;&gt;around_action&lt;/code&gt;. Tous les onglets rafraîchissent. C’est en général ce que vous voulez depuis un job.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Sans en-tête &lt;code class=&quot;highlighter-rouge&quot;&gt;X-Turbo-Request-Id&lt;/code&gt;&lt;/strong&gt;, par exemple depuis un autre client HTTP, le &lt;code class=&quot;highlighter-rouge&quot;&gt;around_action&lt;/code&gt; n’a rien à recopier.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;L’ensemble est borné à 20.&lt;/strong&gt; 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.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;À l’inverse, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.refresh&lt;/code&gt; rendu en réponse directe&lt;/strong&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.refresh(request_id: nil)&lt;/code&gt; si cette réponse doit réellement rafraîchir cet onglet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;La variante synchrone &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcast_refresh_to&lt;/code&gt; n’est pas une exception, elle passe elle aussi par &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream_refresh_tag&lt;/code&gt;, dont le défaut est &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.current_request_id&lt;/code&gt; (&lt;a href=&quot;https://github.com/hotwired/turbo-rails/blob/v2.0.23/app/helpers/turbo/streams/action_helper.rb#L40-L46&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;action_helper.rb#L40-L46&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;h3 id=&quot;le-debounce-et-ce-quil-coûte&quot;&gt;Le debounce, et ce qu’il coûte&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcast_refresh_later_to&lt;/code&gt; passe par un &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo::ThreadDebouncer&lt;/code&gt; mémorisé dans &lt;code class=&quot;highlighter-rouge&quot;&gt;Thread.current&lt;/code&gt;, clé &lt;code class=&quot;highlighter-rouge&quot;&gt;(nom de flux, request_id)&lt;/code&gt;, qui programme un &lt;code class=&quot;highlighter-rouge&quot;&gt;Concurrent::ScheduledTask&lt;/code&gt; &lt;strong&gt;0,5 seconde&lt;/strong&gt; 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 &lt;strong&gt;un&lt;/strong&gt; broadcast ; mille flux GID distincts restent mille broadcasts.&lt;/p&gt;

&lt;p&gt;Côté client, &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; ajoute un second debounce de 150 ms.&lt;/p&gt;

&lt;p&gt;Le revers :&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-piege&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Piège&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;Un processus qui sort avant la fin du délai perd le broadcast.&lt;/strong&gt; Un &lt;code class=&quot;highlighter-rouge&quot;&gt;rails runner&lt;/code&gt;, 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;ScheduledTask&lt;/code&gt;, il n’y a ni erreur ni log. Pour un processus one-shot, la variante synchrone &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcast_refresh_to&lt;/code&gt; évite ce timing. Ajouter un &lt;code class=&quot;highlighter-rouge&quot;&gt;sleep Turbo::Debouncer::DEFAULT_DELAY + 0.1&lt;/code&gt; reste un workaround, pas un contrat documenté.&lt;/p&gt;

  &lt;p&gt;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é.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Dans les tests Rails qui chargent &lt;code class=&quot;highlighter-rouge&quot;&gt;ActiveSupport::TestCase&lt;/code&gt;, il n’y a pas de debounce.&lt;/strong&gt; L’initializer de turbo-rails remplace alors le debouncer par &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo::ImmediateDebouncer&lt;/code&gt;. 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;ActiveSupport::TestCase&lt;/code&gt; ne bénéficie pas automatiquement de ce remplacement.&lt;/p&gt;

&lt;h3 id=&quot;current_user-nest-pas-disponible-comme-dans-une-requête&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; n’est pas disponible comme dans une requête&lt;/h3&gt;

&lt;p&gt;Tout broadcast qui doit rendre son propre HTML, synchrone ou asynchrone, passe par :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;no&quot;&gt;ApplicationController&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;render&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;formats: &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;**&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rendering&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcast_refresh_later_to&lt;/code&gt;, ainsi que les appels qui reçoivent déjà &lt;code class=&quot;highlighter-rouge&quot;&gt;html:&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;content:&lt;/code&gt;, n’ont rien à rendre et ne passent pas par ce renderer. Pour les autres, il s’agit d’un &lt;code class=&quot;highlighter-rouge&quot;&gt;ActionController::Renderer&lt;/code&gt; avec un environnement Rack synthétique. Il n’y a ni session, ni cookies, ni clé Warden. Conséquences mécaniques :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Avec Devise standard, &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; lève &lt;code class=&quot;highlighter-rouge&quot;&gt;Devise::MissingWarden&lt;/code&gt;.&lt;/strong&gt; Le helper appelle &lt;code class=&quot;highlighter-rouge&quot;&gt;warden&lt;/code&gt;, absent de l’environnement synthétique. Un partial qui contient &lt;code class=&quot;highlighter-rouge&quot;&gt;if current_user.admin?&lt;/code&gt; 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.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;Current.*&lt;/code&gt; n’est pas transmis à un worker asynchrone.&lt;/strong&gt; Un job réellement pris par la queue s’exécute dans un autre contexte, sans le state de la requête. &lt;code class=&quot;highlighter-rouge&quot;&gt;perform_now&lt;/code&gt; et l’adapter &lt;code class=&quot;highlighter-rouge&quot;&gt;:inline&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;Current.*&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;ApplicationController&lt;/code&gt; est codé en dur, pas dépouillé de toute sa configuration.&lt;/strong&gt; Le renderer conserve les view paths configurés et, avec le réglage Rails par défaut &lt;code class=&quot;highlighter-rouge&quot;&gt;include_all_helpers&lt;/code&gt;, les helpers applicatifs. En revanche, aucun &lt;code class=&quot;highlighter-rouge&quot;&gt;before_action&lt;/code&gt; ne tourne et aucune ivar de requête n’est préparée. Un &lt;code class=&quot;highlighter-rouge&quot;&gt;helper_method&lt;/code&gt; qui dépend du state de l’instance du controller reste donc inutilisable.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Le résultat des helpers &lt;code class=&quot;highlighter-rouge&quot;&gt;_url&lt;/code&gt; dépend de &lt;code class=&quot;highlighter-rouge&quot;&gt;default_url_options&lt;/code&gt; et du &lt;code class=&quot;highlighter-rouge&quot;&gt;default_env&lt;/code&gt; du renderer.&lt;/strong&gt; Avec ce renderer Rails 8.0.2, l’absence de host configuré produit une URL synthétique sur &lt;code class=&quot;highlighter-rouge&quot;&gt;example.org&lt;/code&gt;. Ne dépendez pas de ce fallback : configurez &lt;code class=&quot;highlighter-rouge&quot;&gt;Rails.application.routes.default_url_options[:host]&lt;/code&gt; pour les broadcasts. Les helpers &lt;code class=&quot;highlighter-rouge&quot;&gt;_path&lt;/code&gt;, eux, n’ont pas besoin de host (&lt;a href=&quot;https://github.com/rails/rails/blob/v8.0.2/actionpack/lib/action_controller/renderer.rb#L105-L110&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;renderer.rb&lt;/code&gt;, v8.0.2&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Le pattern qui passe à l’échelle est le seul qui accepte que le HTML diffusé soit &lt;strong&gt;le même pour tous les destinataires&lt;/strong&gt; : diffuser un partial neutre, et faire charger les parties personnalisées par un frame, que chaque navigateur ira chercher avec ses propres cookies.&lt;/p&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;&amp;lt;%# le partial diffusé, identique pour tous %&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dom_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;card&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;card&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;title&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_frame_tag&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;#{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dom_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;card&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;_actions&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;src: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;card_actions_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;card&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;loading: :lazy&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code class=&quot;highlighter-rouge&quot;&gt;locals:&lt;/code&gt; et traiter « ce partial est diffusable » comme une propriété stricte du partial.&lt;/p&gt;

&lt;p&gt;C’est aussi une raison pratique de préférer &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3 id=&quot;un-flux-signé-nest-pas-un-flux-autorisé&quot;&gt;Un flux signé n’est pas un flux autorisé&lt;/h3&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_stream_from&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;quotes&quot;&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Cette ligne produit un &lt;code class=&quot;highlighter-rouge&quot;&gt;signed-stream-name&lt;/code&gt; &lt;strong&gt;identique pour tous les utilisateurs&lt;/strong&gt;. La signature empêche la falsification, pas la lecture : le nom est un &lt;code class=&quot;highlighter-rouge&quot;&gt;MessageVerifier&lt;/code&gt;, donc du base64 signé, pas du chiffré. Décoder le payload situé avant &lt;code class=&quot;highlighter-rouge&quot;&gt;--&lt;/code&gt; révèle ici la string JSON &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;quotes&quot;&lt;/code&gt;. Avec un record, cette string contient son &lt;code class=&quot;highlighter-rouge&quot;&gt;to_gid_param&lt;/code&gt;, lui-même décodable en &lt;code class=&quot;highlighter-rouge&quot;&gt;gid://app/Account/5&lt;/code&gt;. Aucun de ces décodages ne demande votre clé.&lt;/p&gt;

&lt;p&gt;Et &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo::StreamsChannel#subscribed&lt;/code&gt; &lt;strong&gt;accepte l’abonnement sans aucune vérification supplémentaire&lt;/strong&gt;. Il vérifie que la signature est valide, point.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-retenir&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;À retenir&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;Signer n’est pas autoriser.&lt;/strong&gt; Un &lt;code class=&quot;highlighter-rouge&quot;&gt;signed-stream-name&lt;/code&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Il faut donc porter le périmètre dans le nom du flux :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;broadcasts_refreshes_to&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;quote&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;quote&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;company&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:quotes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-erb highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;turbo_stream_from&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;current_company&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:quotes&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Ce cloisonnement ne remplace pas une autorisation. Pour une appartenance révocable, écrivez votre propre canal et faites le contrôle avant &lt;code class=&quot;highlighter-rouge&quot;&gt;stream_from&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3 id=&quot;pièges-de-plomberie&quot;&gt;Pièges de plomberie&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;En développement, l’adaptateur Action Cable par défaut est &lt;code class=&quot;highlighter-rouge&quot;&gt;async&lt;/code&gt;, mono-processus.&lt;/strong&gt; Un broadcast déclenché depuis &lt;code class=&quot;highlighter-rouge&quot;&gt;bin/rails console&lt;/code&gt; n’atteindra jamais un navigateur connecté à un serveur lancé séparément. Passez sur &lt;code class=&quot;highlighter-rouge&quot;&gt;redis&lt;/code&gt; dans &lt;code class=&quot;highlighter-rouge&quot;&gt;config/cable.yml&lt;/code&gt;, ou utilisez &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= console %&amp;gt;&lt;/code&gt; pour déclencher dans le même processus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Les jobs de turbo-rails héritent de &lt;code class=&quot;highlighter-rouge&quot;&gt;ActiveJob::Base&lt;/code&gt;, pas de votre &lt;code class=&quot;highlighter-rouge&quot;&gt;ApplicationJob&lt;/code&gt;.&lt;/strong&gt; Les retry policies, callbacks et &lt;code class=&quot;highlighter-rouge&quot;&gt;queue_as&lt;/code&gt; définis uniquement sur &lt;code class=&quot;highlighter-rouge&quot;&gt;ApplicationJob&lt;/code&gt; ne s’appliquent donc pas. Les trois classes déclarent &lt;code class=&quot;highlighter-rouge&quot;&gt;discard_on ActiveJob::DeserializationError&lt;/code&gt;, mais elles ne transportent pas les mêmes arguments. Avec &lt;code class=&quot;highlighter-rouge&quot;&gt;ActionBroadcastJob&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;BroadcastJob&lt;/code&gt;, 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;self&lt;/code&gt; aux &lt;code class=&quot;highlighter-rouge&quot;&gt;locals:&lt;/code&gt;. Le stream et la target sont déjà convertis en chaînes avant l’enqueue. &lt;code class=&quot;highlighter-rouge&quot;&gt;BroadcastStreamJob&lt;/code&gt;, utilisé pour un refresh, ne reçoit que le nom du stream et le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; déjà rendu, tous deux sous forme de chaînes. Rails ne repropage pas une erreur de désérialisation traitée par &lt;code class=&quot;highlighter-rouge&quot;&gt;discard_on&lt;/code&gt;, mais écrit un log au niveau &lt;code class=&quot;highlighter-rouge&quot;&gt;error&lt;/code&gt; et émet &lt;code class=&quot;highlighter-rouge&quot;&gt;discard.active_job&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;stimulus-et-les-bibliothèques-tierces&quot;&gt;Stimulus et les bibliothèques tierces&lt;/h2&gt;

&lt;h3 id=&quot;le-cycle-de-vie-précisément&quot;&gt;Le cycle de vie, précisément&lt;/h3&gt;

&lt;p&gt;Stimulus ne sait rien de Turbo. Il réagit à un &lt;code class=&quot;highlighter-rouge&quot;&gt;MutationObserver&lt;/code&gt;, dans la microtask qui suit chaque modification.&lt;/p&gt;

&lt;p&gt;Sur une visite classique qui conserve un snapshot, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; lance la capture, mais le clone n’est pas immédiat : &lt;code class=&quot;highlighter-rouge&quot;&gt;PageView#cacheSnapshot&lt;/code&gt; attend le prochain event loop tick, et la visite n’attend pas cette Promise avant de poursuivre le rendu. L’échange de &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; et la microtask du &lt;code class=&quot;highlighter-rouge&quot;&gt;MutationObserver&lt;/code&gt; peuvent donc faire tourner &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; &lt;strong&gt;avant&lt;/strong&gt; le clone. Un cleanup synchrone dans &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; peut nettoyer aussi la copie différée ; l’ordre n’en fait toutefois pas un contrat. &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; reste le hook public et déterministe pour préparer le snapshot.&lt;/p&gt;

&lt;p&gt;Sous morph, il n’y a &lt;strong&gt;pas de &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect&lt;/code&gt;/&lt;code class=&quot;highlighter-rouge&quot;&gt;connect&lt;/code&gt;&lt;/strong&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;moveBefore&lt;/code&gt;. Changer la valeur de &lt;code class=&quot;highlighter-rouge&quot;&gt;data-controller&lt;/code&gt; les déclenche aussi. &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt;, lui, dépend du chemin de visite comme vu plus haut : absent après une soumission non-GET réussie ; absent pour &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt;, sauf si le LRU contient déjà un snapshot previewable de l’URL courante &lt;strong&gt;et que le document courant est cacheable&lt;/strong&gt; ; présent pour une visite ordinaire qui conserve son snapshot.&lt;/p&gt;

&lt;p&gt;Une fixture de test de Turbo 8.0.23 force la reconnexion ainsi :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo:morph-element&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;element&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;context&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;of&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;application&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;controllers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;element&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;disconnect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Ce n’est pas une API de reconnexion promise par Turbo : la fixture appelle directement le &lt;code class=&quot;highlighter-rouge&quot;&gt;context&lt;/code&gt; de Stimulus et reste liée à cette version. À chaque &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:morph-element&lt;/code&gt;, elle parcourt toute la collection &lt;code class=&quot;highlighter-rouge&quot;&gt;application.controllers&lt;/code&gt;, mais elle ne reconnecte que les contrôleurs dont l’élément est exactement &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt;. Sur une grande page, le coût vient donc du scan répété, pas d’une reconnexion générale.&lt;/p&gt;

&lt;h3 id=&quot;ce-qui-casse-et-pourquoi&quot;&gt;Ce qui casse, et pourquoi&lt;/h3&gt;

&lt;blockquote class=&quot;callout callout-retenir&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;À retenir&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;Toute bibliothèque qui injecte du DOM absent du HTML serveur, ou qui écrit &lt;code class=&quot;highlighter-rouge&quot;&gt;class&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;style&lt;/code&gt; au runtime, peut entrer en conflit avec le morphing.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Bibliothèque&lt;/th&gt;
      &lt;th&gt;Ce qui casse&lt;/th&gt;
      &lt;th&gt;La cause&lt;/th&gt;
      &lt;th&gt;La parade&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Chartkick&lt;/td&gt;
      &lt;td&gt;Le graphique disparaît, un « Loading… » reste&lt;/td&gt;
      &lt;td&gt;Chartkick écoute &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-render&lt;/code&gt; et détruit tous les graphiques, y compris quand &lt;code class=&quot;highlighter-rouge&quot;&gt;renderMethod&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt;. Ce comportement n’est pas celui de Chart.js utilisé directement. Le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; qui recréerait le graphique ne se réexécute pas&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;Chartkick.config.autoDestroy = false&lt;/code&gt;, puis redessiner sur &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:morph&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Alpine.js&lt;/td&gt;
      &lt;td&gt;Les éléments redeviennent invisibles, les &lt;code class=&quot;highlighter-rouge&quot;&gt;:class&lt;/code&gt; sautent&lt;/td&gt;
      &lt;td&gt;Alpine retire &lt;code class=&quot;highlighter-rouge&quot;&gt;x-cloak&lt;/code&gt; à l’init et écrit &lt;code class=&quot;highlighter-rouge&quot;&gt;class&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;style.display&lt;/code&gt; à l’exécution. Le HTML serveur ne les connaît pas, le morph les rétablit&lt;/td&gt;
      &lt;td&gt;Annuler &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-morph-attribute&lt;/code&gt; pour &lt;code class=&quot;highlighter-rouge&quot;&gt;x-cloak&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;class&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;style&lt;/code&gt; sur les sous-arbres &lt;code class=&quot;highlighter-rouge&quot;&gt;[x-data]&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Tom Select, Select2, Choices&lt;/td&gt;
      &lt;td&gt;Le widget disparaît, la sélection revient en arrière&lt;/td&gt;
      &lt;td&gt;Le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;div&amp;gt;&lt;/code&gt; injecté n’est pas dans le HTML serveur, donc supprimé. Le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;select&amp;gt;&lt;/code&gt; d’origine survit, donc pas de reconnexion&lt;/td&gt;
      &lt;td&gt;Rendre côté serveur un wrapper stable qui contient le contrôle et le DOM injecté, puis poser &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; sur ce parent. Sinon, teardown avant son morph et réinitialisation après&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Leaflet, Mapbox GL&lt;/td&gt;
      &lt;td&gt;Carte morte, tuiles grises, ou &lt;code class=&quot;highlighter-rouge&quot;&gt;Map container is already initialized&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Le DOM interne injecté par la bibliothèque est supprimé, tandis que le conteneur survit ; &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; ne tourne donc pas&lt;/td&gt;
      &lt;td&gt;Détruire avant le morph du conteneur, réinitialiser sur &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:morph-element&lt;/code&gt;, puis appeler &lt;code class=&quot;highlighter-rouge&quot;&gt;invalidateSize()&lt;/code&gt; avec Leaflet ou &lt;code class=&quot;highlighter-rouge&quot;&gt;resize()&lt;/code&gt; avec Mapbox GL&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; ouvert en &lt;code class=&quot;highlighter-rouge&quot;&gt;showModal()&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;La page devient inutilisable&lt;/td&gt;
      &lt;td&gt;Le morph réécrit le contenu mais ne réinitialise pas le &lt;em&gt;top layer&lt;/em&gt; du navigateur&lt;/td&gt;
      &lt;td&gt;Ticket ouvert et non résolu. Fermer le dialogue sur &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-render&lt;/code&gt;, ou l’exclure du morph&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;details&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Les panneaux s’ouvrent ou se ferment tout seuls chez tous les spectateurs&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;open&lt;/code&gt; est synchronisé comme un attribut ordinaire&lt;/td&gt;
      &lt;td&gt;Annuler &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-morph-attribute&lt;/code&gt; pour &lt;code class=&quot;highlighter-rouge&quot;&gt;attributeName === &quot;open&quot;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-cable-stream-source&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Des broadcasts sont perdus, ou l’UI croit le câble hors ligne&lt;/td&gt;
      &lt;td&gt;Un reparenting via le fallback &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt; le désabonne ; un morph en place peut retirer l’attribut runtime &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt; sans reconnecter l’élément&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; stable, hors des zones réordonnées, et protection de l’attribut &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;Trix et Action Text sont corrigés depuis mars 2025&lt;/strong&gt;, contrairement à ce qu’affirment encore beaucoup de billets. La technique employée donne un pattern utile pour un custom element sous morph : &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;trix-editor&amp;gt;&lt;/code&gt; pose un attribut &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt; à l’initialisation et le déclare dans &lt;code class=&quot;highlighter-rouge&quot;&gt;observedAttributes&lt;/code&gt;. Le HTML serveur ne le contient pas, donc &lt;strong&gt;le morph le retire&lt;/strong&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;attributeChangedCallback&lt;/code&gt; se déclenche, et l’élément se réinitialise tout seul.&lt;/p&gt;

&lt;p&gt;C’est un custom element auto-réparant. Si vous écrivez le vôtre, faites ça.&lt;/p&gt;

&lt;h3 id=&quot;ce-qui-fuit-si-vous-ne-nettoyez-pas&quot;&gt;Ce qui fuit si vous ne nettoyez pas&lt;/h3&gt;

&lt;p&gt;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 &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; : les &lt;code class=&quot;highlighter-rouge&quot;&gt;setInterval&lt;/code&gt;, les &lt;code class=&quot;highlighter-rouge&quot;&gt;addEventListener&lt;/code&gt; sur &lt;code class=&quot;highlighter-rouge&quot;&gt;window&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;document&lt;/code&gt;, les &lt;code class=&quot;highlighter-rouge&quot;&gt;IntersectionObserver&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;ResizeObserver&lt;/code&gt; (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 &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; ne doit évidemment pas la détruire.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3 id=&quot;injecter-un-stream-sans-réponse-http&quot;&gt;Injecter un stream sans réponse HTTP&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.renderStreamMessage()&lt;/code&gt; accepte une chaîne HTML contenant des &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; et les applique comme s’ils étaient arrivés par le réseau. Les &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; à l’intérieur des &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt; sont activés, les éléments permanents préservés, le focus restauré par &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;C’est ce qu’il faut pour un transport autre qu’Action Cable (WebSocket brut, SSE, &lt;code class=&quot;highlighter-rouge&quot;&gt;postMessage&lt;/code&gt; 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.&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;Turbo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;renderStreamMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;s2&quot;&gt;`&amp;lt;turbo-stream action=&quot;append&quot; target=&quot;messages&quot;&amp;gt;&amp;lt;template&amp;gt;…&amp;lt;/template&amp;gt;&amp;lt;/turbo-stream&amp;gt;`&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;réseau-et-hors-ligne&quot;&gt;Réseau et hors-ligne&lt;/h2&gt;

&lt;h3 id=&quot;quand-la-requête-échoue-il-ne-se-passe-rien&quot;&gt;Quand la requête échoue, il ne se passe rien&lt;/h3&gt;

&lt;p&gt;Turbo émet &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt;, qui bubble jusqu’à &lt;code class=&quot;highlighter-rouge&quot;&gt;document&lt;/code&gt;, y compris à travers les &lt;em&gt;shadow roots&lt;/em&gt;, avec &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.request&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.error&lt;/code&gt;. Un listener posé sur &lt;code class=&quot;highlighter-rouge&quot;&gt;document&lt;/code&gt; l’attrape de façon fiable.&lt;/p&gt;

&lt;p&gt;Ce qui arrive ensuite dépend du contexte, et la différence est brutale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pour un frame ou une soumission de formulaire, l’utilisateur ne voit rien.&lt;/strong&gt; Pas de bandeau, pas de toast. &lt;code class=&quot;highlighter-rouge&quot;&gt;console.error&lt;/code&gt;, et le frame reste sur son ancien contenu.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pour une visite Drive, Turbo quitte la page.&lt;/strong&gt; L’échec réseau est enregistré comme &lt;code class=&quot;highlighter-rouge&quot;&gt;SystemStatusCode.networkFailure&lt;/code&gt;, l’adaptateur émet &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:reload&lt;/code&gt; avec &lt;code class=&quot;highlighter-rouge&quot;&gt;reason: &quot;request_failed&quot;&lt;/code&gt;, 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.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;interne&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Le nom &lt;code class=&quot;highlighter-rouge&quot;&gt;reload&lt;/code&gt; induit en erreur, et j’ai mis du temps à comprendre ce qui se passait vraiment :&lt;/p&gt;

  &lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nf&quot;&gt;reload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;reason&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nf&quot;&gt;dispatch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo:reload&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;reason&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
  &lt;span class=&quot;nb&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;href&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;redirectedToLocation&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)?.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;href&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;  &lt;/div&gt;

  &lt;p&gt;(&lt;a href=&quot;https://github.com/hotwired/turbo/blob/v8.0.23/src/core/native/browser_adapter.js#L130-L134&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;browser_adapter.js#L130-L134&lt;/code&gt;&lt;/a&gt;) Ce n’est &lt;strong&gt;pas&lt;/strong&gt; un &lt;code class=&quot;highlighter-rouge&quot;&gt;location.reload()&lt;/code&gt;. C’est une navigation pleine page vers &lt;strong&gt;la destination de la visite&lt;/strong&gt;, 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.&lt;/p&gt;

  &lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:reload&lt;/code&gt; est en outre dispatché &lt;strong&gt;sans &lt;code class=&quot;highlighter-rouge&quot;&gt;cancelable&lt;/code&gt;&lt;/strong&gt;, donc l’écouter ne permet d’empêcher quoi que ce soit. Son &lt;code class=&quot;highlighter-rouge&quot;&gt;detail&lt;/code&gt; est l’objet raison complet : &lt;code class=&quot;highlighter-rouge&quot;&gt;event.detail.reason&lt;/code&gt; vaut &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;request_failed&quot;&lt;/code&gt;, et &lt;code class=&quot;highlighter-rouge&quot;&gt;event.detail.context.statusCode&lt;/code&gt; porte le code interne.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;D’où le seul point d’interception qui fonctionne, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt;, plus tôt dans la chaîne. Le &lt;code class=&quot;highlighter-rouge&quot;&gt;preventDefault()&lt;/code&gt; n’y est pas cosmétique, il fait retourner &lt;code class=&quot;highlighter-rouge&quot;&gt;false&lt;/code&gt; à &lt;code class=&quot;highlighter-rouge&quot;&gt;#willDelegateErrorHandling&lt;/code&gt;, ce qui court-circuite &lt;code class=&quot;highlighter-rouge&quot;&gt;requestErrored&lt;/code&gt;, donc &lt;code class=&quot;highlighter-rouge&quot;&gt;recordResponse&lt;/code&gt;, donc l’adaptateur n’est jamais sollicité et &lt;strong&gt;la navigation n’a pas lieu&lt;/strong&gt;. En contrepartie, &lt;code class=&quot;highlighter-rouge&quot;&gt;FetchRequest#perform&lt;/code&gt; rethrow quand même l’erreur : attendez-vous à une unhandled promise rejection dans la console.&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo:fetch-request-error&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;preventDefault&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;// empêche aussi le rechargement complet sur une visite Drive&lt;/span&gt;
  &lt;span class=&quot;nf&quot;&gt;showOfflineBanner&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;offline&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;showOfflineBanner&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;online&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;  &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;hideOfflineBanner&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Attention à l’ordre des événements : &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-response&lt;/code&gt; ne se déclenche &lt;strong&gt;que s’il y a une réponse&lt;/strong&gt;. Une détection de perte réseau construite dessus ne se déclenchera jamais quand le réseau est réellement coupé.&lt;/p&gt;

&lt;h3 id=&quot;turbo-et-les-service-workers&quot;&gt;Turbo et les service workers&lt;/h3&gt;

&lt;p&gt;Les navigations Turbo sont des appels &lt;code class=&quot;highlighter-rouge&quot;&gt;window.fetch()&lt;/code&gt;. Turbo ne passe pas d’option &lt;code class=&quot;highlighter-rouge&quot;&gt;mode&lt;/code&gt;, donc côté service worker la requête a &lt;code class=&quot;highlighter-rouge&quot;&gt;mode === &quot;cors&quot;&lt;/code&gt; et, ce qui surprend davantage, &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;destination === &quot;&quot;&lt;/code&gt;&lt;/strong&gt;, la valeur par défaut de tout ce qui sort de &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Un service worker qui route sur &lt;code class=&quot;highlighter-rouge&quot;&gt;request.mode === &#39;navigate&#39;&lt;/code&gt; rate donc toutes les navigations Drive. Et le correctif qu’on lit partout, ajouter &lt;code class=&quot;highlighter-rouge&quot;&gt;|| request.destination === &#39;document&#39;&lt;/code&gt;, ne rattrape rien du tout, car ce &lt;code class=&quot;highlighter-rouge&quot;&gt;destination&lt;/code&gt; n’existe que pour les navigations initiées par le navigateur lui-même. Il faut discriminer sur ce que Turbo, lui, envoie vraiment :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;isDocumentNavigation&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;accept&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;Accept&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&#39;&#39;&lt;/span&gt;

  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;method&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;GET&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;mode&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;navigate&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt;                                   &lt;span class=&quot;c1&quot;&gt;// navigation navigateur&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;destination&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&#39;&#39;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;                                   &lt;span class=&quot;c1&quot;&gt;// fetch() de Turbo Drive&lt;/span&gt;
     &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;has&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;Turbo-Frame&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;                           &lt;span class=&quot;c1&quot;&gt;// pas une requête de frame&lt;/span&gt;
     &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;accept&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;includes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;text/vnd.turbo-stream.html&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;                &lt;span class=&quot;c1&quot;&gt;// pas un GET stream&lt;/span&gt;
     &lt;span class=&quot;nx&quot;&gt;accept&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;includes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;text/html&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nf&quot;&gt;registerRoute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;isDocumentNavigation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;NetworkFirst&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
&lt;span class=&quot;nf&quot;&gt;registerRoute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;style&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;font&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&#39;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;includes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;destination&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;CacheFirst&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Les deux exclusions sont indispensables : Frames et Streams utilisent eux aussi &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch()&lt;/code&gt;, portent &lt;code class=&quot;highlighter-rouge&quot;&gt;destination === &quot;&quot;&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;destination&lt;/code&gt; renseigné.&lt;/p&gt;

&lt;p&gt;Ce matcher suppose aussi que vos autres &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch()&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt; et faites porter le prédicat dessus.&lt;/p&gt;

&lt;p&gt;Deux pannes de service worker reviennent souvent avec Turbo : un mauvais &lt;code class=&quot;highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt; et des asset fingerprints incohérents. Ce ne sont pas les deux seules possibles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Servir une réponse au mauvais &lt;code class=&quot;highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt;.&lt;/strong&gt; Sur une visite Drive, Turbo enregistre &lt;code class=&quot;highlighter-rouge&quot;&gt;contentTypeMismatch&lt;/code&gt;, émet &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:reload&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Servir un HTML dont les asset fingerprints ne correspondent plus.&lt;/strong&gt; Turbo compare les éléments &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-track=&quot;reload&quot;&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Rails 8 ajoute ses propres contraintes :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Placez le service worker à une URL racine stable.&lt;/strong&gt; Par défaut, son scope ne peut pas remonter au-dessus du répertoire de son URL. Le header HTTP &lt;code class=&quot;highlighter-rouge&quot;&gt;Service-Worker-Allowed&lt;/code&gt; peut élargir ce scope, mais il ne règle pas l’URL instable d’un asset fingerprinté par Propshaft. Deux options simples : &lt;code class=&quot;highlighter-rouge&quot;&gt;public/service-worker.js&lt;/code&gt;, ou la route que Rails 8 génère, qui est &lt;strong&gt;commentée par défaut&lt;/strong&gt; dans &lt;code class=&quot;highlighter-rouge&quot;&gt;config/routes.rb&lt;/code&gt; et qu’il faut donc décommenter : &lt;code class=&quot;highlighter-rouge&quot;&gt;get &quot;service-worker&quot; =&amp;gt; &quot;rails/pwa#service_worker&quot;, as: :pwa_service_worker&lt;/code&gt;. Notez le controller &lt;code class=&quot;highlighter-rouge&quot;&gt;rails/pwa&lt;/code&gt; et le chemin sans &lt;code class=&quot;highlighter-rouge&quot;&gt;.js&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Les importmaps ne s’appliquent pas dans un service worker.&lt;/strong&gt; Il n’y a pas de &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script type=&quot;importmap&quot;&amp;gt;&lt;/code&gt; là-bas. Utilisez &lt;code class=&quot;highlighter-rouge&quot;&gt;importScripts()&lt;/code&gt; dans un classic worker, ou enregistrez un module worker avec &lt;code class=&quot;highlighter-rouge&quot;&gt;{ type: &quot;module&quot; }&lt;/code&gt; et des imports statiques résolus comme de vraies URL de modules, sans importmap.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Les URL d’assets fingerprintés changent avec leur contenu.&lt;/strong&gt; Un precache manifest écrit à la main périme au premier déploiement qui touche un fichier. Préférez le runtime caching, par &lt;code class=&quot;highlighter-rouge&quot;&gt;request.destination&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Un patch officiel est en cours (&lt;a href=&quot;https://github.com/hotwired/turbo/pull/1427&quot;&gt;turbo#1427&lt;/a&gt;, une API &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.offline&lt;/code&gt; livrée dans un bundle séparé) mais il est &lt;strong&gt;ouvert, pas fusionné&lt;/strong&gt;. N’écrivez pas d’architecture qui en dépende.&lt;/p&gt;

&lt;h3 id=&quot;action-cable-ne-rejoue-rien&quot;&gt;Action Cable ne rejoue rien&lt;/h3&gt;

&lt;p&gt;Ce risque de production reste peu documenté dans Hotwire.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Action Cable se reconnecte tout seul, et l’onglet reçoit de nouveau les messages &lt;strong&gt;suivants&lt;/strong&gt;. Tout ce qui est passé pendant la coupure est &lt;strong&gt;définitivement perdu&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;La surface publique d’observation dans le DOM tient dans un attribut : &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-cable-stream-source&amp;gt;&lt;/code&gt; pose et retire &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt;. On peut instrumenter les callbacks de subscription ou le consumer Action Cable, mais au prix d’un couplage à leurs internals.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Sous le capot&lt;/strong&gt; &lt;span class=&quot;tag tag-observe&quot;&gt;vérifié&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;L’élément dispatche bien un événement, mais pas celui qu’on voudrait : un &lt;code class=&quot;highlighter-rouge&quot;&gt;MessageEvent(&quot;message&quot;)&lt;/code&gt; &lt;strong&gt;par payload reçu&lt;/strong&gt;, qui est le canal par lequel &lt;code class=&quot;highlighter-rouge&quot;&gt;StreamObserver&lt;/code&gt; le consomme (&lt;a href=&quot;https://github.com/hotwired/turbo-rails/blob/v2.0.23/app/javascript/turbo/cable_stream_source_element.js#L30-L33&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;cable_stream_source_element.js#L30-L33&lt;/code&gt;&lt;/a&gt;). Ce qu’il ne dispatche pas, c’est le moindre événement de &lt;strong&gt;cycle de vie&lt;/strong&gt; : &lt;code class=&quot;highlighter-rouge&quot;&gt;subscriptionConnected&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;subscriptionDisconnected&lt;/code&gt; se contentent de poser et retirer l’attribut.&lt;/p&gt;

  &lt;p&gt;Pour rester sur l’interface publique du custom element, observez donc cet attribut avec un &lt;code class=&quot;highlighter-rouge&quot;&gt;MutationObserver&lt;/code&gt; ou, pour un simple bandeau, avec du CSS : &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-cable-stream-source:not([connected]) ~ .offline-banner { display: block }&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;D’où la parade, qui est entièrement du code applicatif. &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; stable, avant de l’observer :&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo:before-morph-attribute&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;matches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;turbo-cable-stream-source&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;attributeName&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;connected&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;preventDefault&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// contrôleur Stimulus posé sur &amp;lt;turbo-cable-stream-source&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nf&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;observer&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;MutationObserver&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;isConnected&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;element&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;hasAttribute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;connected&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;isConnected&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;wasDisconnected&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;Turbo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;visit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;href&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;replace&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;wasDisconnected&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;isConnected&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;observer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;observe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;element&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;attributeFilter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;connected&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nf&quot;&gt;disconnect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;observer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;disconnect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Traitez aussi &lt;code class=&quot;highlighter-rouge&quot;&gt;visibilitychange&lt;/code&gt; (le réveil de la machine) et &lt;code class=&quot;highlighter-rouge&quot;&gt;window.online&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Avec &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_refreshes_with method: :morph, scroll: :preserve&lt;/code&gt;, 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;input&amp;gt;&lt;/code&gt; ou un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; identifié. &lt;strong&gt;C’est ce qui rend le couple morphing + broadcasts praticable&lt;/strong&gt; : le rattrapage devient assez bon marché pour qu’on l’écrive.&lt;/p&gt;

&lt;p&gt;Dernière chose à savoir dans le même registre. &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; abandonne aussi, sans retry, un refresh qui arrive pendant qu’une navigation est déjà en cours (&lt;code class=&quot;highlighter-rouge&quot;&gt;!this.navigator.currentVisit&lt;/code&gt;). C’est un second chemin, plus étroit, vers la même péremption.&lt;/p&gt;

&lt;h2 id=&quot;checklist-de-debug&quot;&gt;Checklist de debug&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. La console, avant le réseau.&lt;/strong&gt; Un &lt;code class=&quot;highlighter-rouge&quot;&gt;console.error&lt;/code&gt; change complètement le diagnostic. &lt;code class=&quot;highlighter-rouge&quot;&gt;Form responses must redirect&lt;/code&gt; → statut HTTP. &lt;code class=&quot;highlighter-rouge&quot;&gt;unknown action&lt;/code&gt; ou &lt;code class=&quot;highlighter-rouge&quot;&gt;target or targets attribute is missing&lt;/code&gt; → le &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; est mal formé. &lt;code class=&quot;highlighter-rouge&quot;&gt;Content missing&lt;/code&gt; → identifiant de frame. &lt;strong&gt;Console vide et rien à l’écran :&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. La requête est-elle partie, et avec quels en-têtes.&lt;/strong&gt; Dans l’onglet Réseau, vérifiez &lt;code class=&quot;highlighter-rouge&quot;&gt;Accept&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo-Frame&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;X-Turbo-Request-Id&lt;/code&gt;. L’absence de &lt;code class=&quot;highlighter-rouge&quot;&gt;text/vnd.turbo-stream.html&lt;/code&gt; dans &lt;code class=&quot;highlighter-rouge&quot;&gt;Accept&lt;/code&gt; empêche la négociation habituelle du format, mais une extension &lt;code class=&quot;highlighter-rouge&quot;&gt;.turbo_stream&lt;/code&gt; ou un &lt;code class=&quot;highlighter-rouge&quot;&gt;params[:format]&lt;/code&gt; 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 &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo=&quot;false&quot;&lt;/code&gt; sur un ancêtre.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Le statut, et seulement à ce moment-là.&lt;/strong&gt; 200 sans redirection sur un POST est le classique. Un Content-Type &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-stream&lt;/code&gt; choisit le stream renderer avant que Turbo consulte les page/frame renderers, mais le statut alimente toujours &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:submit-end.detail.success&lt;/code&gt;. Pour une navigation de frame lancée par link ou &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;, 2xx et 4xx/5xx HTML passent tous par &lt;code class=&quot;highlighter-rouge&quot;&gt;loadResponse()&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. La cible existe-t-elle vraiment, à cet instant.&lt;/strong&gt; Dans la console, &lt;code class=&quot;highlighter-rouge&quot;&gt;document.getElementById(&quot;votre_id&quot;)&lt;/code&gt;. Vérifiez aussi qu’elle n’est pas dans un &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt;, dans une &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;, ou dans un frame &lt;code class=&quot;highlighter-rouge&quot;&gt;loading=&quot;lazy&quot;&lt;/code&gt; pas encore chargé : trois endroits où Turbo ne regardera pas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Combien y en a-t-il ?&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;document.querySelectorAll(&quot;#votre_id&quot;).length&lt;/code&gt;. La réponse doit être &lt;code class=&quot;highlighter-rouge&quot;&gt;1&lt;/code&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;2&lt;/code&gt; explique à la fois les streams qui visent le mauvais élément et les morphs qui se comportent bizarrement à distance.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;7. Est-ce que ça vient d’un broadcast.&lt;/strong&gt; Regardez si le HTML reçu est le même pour tout le monde. Avec Devise standard, un partial diffusé qui appelle &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; é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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Le même test dans Safari, ou sur un iPhone.&lt;/strong&gt; Une différence limitée à ces navigateurs justifie de vérifier &lt;code class=&quot;highlighter-rouge&quot;&gt;Element.prototype.moveBefore&lt;/code&gt; et le fallback &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Coupez le réseau, et regardez ce qui se passe.&lt;/strong&gt; C’est le seul moyen de découvrir qu’un clic de lien hors ligne quitte votre application, et que personne n’écoute &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;index-par-symptôme&quot;&gt;Index par symptôme&lt;/h2&gt;

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

&lt;h2 id=&quot;ce-qui-a-changé-récemment&quot;&gt;Ce qui a changé récemment&lt;/h2&gt;

&lt;p&gt;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 :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-cache=&quot;false&quot;&lt;/code&gt; a été &lt;strong&gt;supprimé&lt;/strong&gt;. C’est &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-temporary&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.clearCache()&lt;/code&gt; a été &lt;strong&gt;supprimé&lt;/strong&gt;. C’est &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.cache.clear()&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-frame=&quot;_parent&quot;&lt;/code&gt; a été &lt;strong&gt;ajouté&lt;/strong&gt;.&lt;/li&gt;
  &lt;li&gt;Les attributs &lt;code class=&quot;highlighter-rouge&quot;&gt;method&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;scroll&lt;/code&gt; sur l’action de stream &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; ont été ajoutés : ils écrasent les balises meta, broadcast par broadcast.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;before&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;after&lt;/code&gt; dédoublonnent désormais les frères, comme &lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;prepend&lt;/code&gt; le faisaient déjà pour les enfants.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Et quelques éléments présents dans le code mais absents de la référence officielle, tous à ranger sous l’étiquette &lt;span class=&quot;tag tag-observe&quot;&gt;vérifié&lt;/span&gt; : l’événement &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-frame-morph&lt;/code&gt;, les options &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.forms.mode&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.forms.submitter&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.drive.enabled&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.drive.unvisitableExtensions&lt;/code&gt;, la réassignation de &lt;code class=&quot;highlighter-rouge&quot;&gt;event.detail.url&lt;/code&gt; sur &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt;, et le fait que &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-render&lt;/code&gt; soit déclaré annulable alors que l’annuler ne produit aucun effet.&lt;/p&gt;

&lt;p&gt;Les détails internes sont reliés au code ou aux tests des versions épinglées ; relisez ces sources à chaque montée de Turbo.&lt;/p&gt;

&lt;p&gt;Gardez surtout la règle simple : &lt;strong&gt;Drive tant qu’une navigation suffit, Frame pour un fragment adressable, Stream quand le serveur doit orchestrer plusieurs cibles.&lt;/strong&gt; Le cache, le morphing et les broadcasts ne changent pas ce périmètre ; ils changent le trajet ou le rendu.&lt;/p&gt;

&lt;p&gt;Si votre application Hotwire accumule ces symptômes, c’est mon travail chez &lt;a href=&quot;/contact/?ref=turbo-reference&quot;&gt;SXN Labs&lt;/a&gt;. Je pars d’un cas reproductible, corrige le contrat rompu et vérifie le résultat en production.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="ruby" />
    <category term="Hotwire" />
    <category term="Turbo" />
    <category term="Rails" />
    <category term="Morphing" />
    <category term="Stimulus" />
    <summary type="html">Une grille simple pour choisir entre Drive, Frames et Streams, puis diagnostiquer le cache, le morphing et les broadcasts dans Turbo 8.0.23.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-08-06-turbo-drive-frames-streams-reference.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Six lots en parallèle, et la facture arrive à l&#39;intégration</title>
    <link href="https://sxnlabs.com/opinion/2026/08/05/parallelisme-la-facture-arrive-a-l-integration/" rel="alternate" type="text/html" title="Six lots en parallèle, et la facture arrive à l&#39;intégration" />
    <published>2026-08-05T09:00:00+02:00</published>
    <updated>2026-08-05T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/opinion/2026/08/05/parallelisme-la-facture-arrive-a-l-integration/</id>
    <content type="html" xml:base="https://sxnlabs.com/opinion/2026/08/05/parallelisme-la-facture-arrive-a-l-integration/">&lt;p&gt;Six lots fonctionnels, chacun testé et relu, ont produit dix défauts sérieux une fois réunis. Ils avaient été conçus comme indépendants alors qu’ils modifiaient les mêmes devis, sites d’intervention et contacts, appliquaient les mêmes règles métier et réécrivaient certains fichiers communs.&lt;/p&gt;

&lt;p&gt;Le logiciel planifie des interventions, envoie des devis à signer et prévient les clients par SMS. Le parallélisme avait raccourci le développement de chaque lot tandis que le travail nécessaire pour obtenir un produit cohérent restait intact.&lt;/p&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;picture&gt;
    &lt;source media=&quot;(max-width: 640px)&quot; srcset=&quot;/images/posts/parallelisme-integration/convergence-mobile.fr.svg?v=20260827-layering&quot; width=&quot;375&quot; height=&quot;564&quot; /&gt;
    &lt;img width=&quot;940&quot; height=&quot;494&quot; src=&quot;/images/posts/parallelisme-integration/convergence.fr.svg?v=20260827-layering&quot; alt=&quot;Six lots validés séparément convergent deux par deux vers les mêmes objets, règles métier et artefacts, puis vers une intégration qui révèle dix défauts sérieux.&quot; /&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;Le découpage séparait les tickets tout en laissant communes les surfaces qu&#39;ils modifiaient.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;deux-lots-corrects-un-parcours-incohérent&quot;&gt;Deux lots corrects, un parcours incohérent&lt;/h2&gt;

&lt;p&gt;Un lot ajoutait l’acceptation d’un devis en ligne : le système vérifiait le devis, confirmait la commande et produisait la facture. Un autre permettait au back-office de modifier le site d’intervention ou de supprimer la demande associée. Chaque comportement était cohérent et couvert par ses tests.&lt;/p&gt;

&lt;p&gt;Ensemble, ils ouvraient une brèche entre la vérification et la confirmation. Le back-office pouvait modifier le site d’intervention pendant que le client signait. Le client validait alors la version courante du devis, tandis que le prestataire de signature conservait un contrat décrivant l’ancienne version.&lt;/p&gt;

&lt;p&gt;Aucun test isolé ne couvrait cette séquence. Le défaut se trouvait dans leur jointure, autour d’un state partagé qui pouvait changer avant l’acte irréversible.&lt;/p&gt;

&lt;h2 id=&quot;les-tickets-masquaient-un-système-partagé&quot;&gt;Les tickets masquaient un système partagé&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Une règle métier dupliquée.&lt;/strong&gt; Trois chemins devaient conserver le destinataire d’un devis, avec trois définitions : une empreinte complète du contact, son identifiant ou aucune vérification.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Un state relu trop tôt.&lt;/strong&gt; Deux requêtes pouvaient charger le même brouillon et générer deux PDF, sans moyen fiable de déterminer ensuite lequel représentait l’offre acceptée.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Un artefact partagé.&lt;/strong&gt; Deux branches réécrivaient le schéma de la base. Le merge avait supprimé une table nécessaire aux rendez-vous, malgré un code correct dans chaque branche.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;produire-plus-vite-déplace-le-goulot&quot;&gt;Produire plus vite déplace le goulot&lt;/h2&gt;

&lt;p&gt;Lorsque chaque lot peut interagir avec tous les autres, le nombre maximal de paires à examiner suit la formule &lt;code class=&quot;highlighter-rouge&quot;&gt;n × (n - 1) / 2&lt;/code&gt; :&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Lots parallèles&lt;/th&gt;
      &lt;th&gt;Calcul&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Paires possibles&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;6&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;6 × 5 / 2&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;15&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;10 × 9 / 2&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;45&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Ces 15 ou 45 paires ne correspondent pas à autant de conflits. Elles forment un plafond ; le coût réel dépend de la densité du graphe de dépendances et du coût de chaque jointure.&lt;/p&gt;

&lt;p&gt;L’IA permet à un développeur de lancer plusieurs chantiers à faible coût. La génération, les tests locaux et la review se parallélisent bien. Réconcilier deux interprétations d’une règle métier reste un travail séquentiel qui exige de comprendre le produit entier.&lt;/p&gt;

&lt;h2 id=&quot;avant-de-lancer-les-lots-en-parallèle&quot;&gt;Avant de lancer les lots en parallèle&lt;/h2&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;picture&gt;
    &lt;source media=&quot;(max-width: 640px)&quot; srcset=&quot;/images/posts/parallelisme-integration/stack-devis-signature-read-write-mobile.fr.svg&quot; width=&quot;375&quot; height=&quot;810&quot; /&gt;
    &lt;img width=&quot;940&quot; height=&quot;544&quot; src=&quot;/images/posts/parallelisme-integration/stack-devis-signature-read-write.fr.svg&quot; alt=&quot;Le back-office modifie le site d&#39;intervention que le parcours de signature lit. Cette dépendance impose une coordination et un test commun ; les PR sont empilées seulement si la seconde dépend du code de la première.&quot; /&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;Le chevauchement lecture/écriture impose une coordination, pas automatiquement une stack. La seconde PR part de la première seulement si elle dépend de son code ou de son schéma.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Cartographier les lectures et écritures.&lt;/strong&gt; Pour chaque lot, noter les objets lus ou modifiés, les invariants et les artefacts partagés.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Classer les dépendances.&lt;/strong&gt; Si un lot écrit ce qu’un autre lit ou écrit, ou s’ils partagent un invariant ou un artefact, leur intégration doit être planifiée ensemble.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Empiler les dépendances de code.&lt;/strong&gt; Si une PR utilise le code ou le schéma de la précédente, la faire partir de cette branche pour qu’elle n’affiche que son diff. Fusionner la stack du bas vers le haut et restack ses descendantes après chaque changement. Une dépendance métier seule exige une coordination d’intégration et un test commun, pas forcément une stack.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Fermer les races.&lt;/strong&gt; Lier la signature à une version immuable du devis et du site, puis comparer cette version dans la même transaction que la confirmation. Si elle diffère, invalider la signature et régénérer le contrat.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Tester après chaque merge.&lt;/strong&gt; Régénérer les artefacts partagés, puis rejouer les parcours métier touchés. Une suite verte sur chaque PR ne couvre pas leurs jointures.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Planifier l’intégration.&lt;/strong&gt; Nommer la personne qui intègre, fixer la cadence et réserver le temps nécessaire. La date annoncée doit inclure ce travail.&lt;/li&gt;
&lt;/ol&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opinion" />
    <category term="Méthode" />
    <category term="Projet" />
    <category term="Intégration" />
    <category term="IA" />
    <summary type="html">Six lots corrects séparément ont créé dix défauts à l&#39;intégration. Le coût du parallélisme dépend des règles, états et artefacts qu&#39;ils partagent.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-08-05-parallelisme-la-facture-arrive-a-l-integration.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Le contraste ne se vérifie pas seulement dans la maquette</title>
    <link href="https://sxnlabs.com/web/2026/07/30/accessibilite-ne-se-verifie-pas-dans-la-maquette/" rel="alternate" type="text/html" title="Le contraste ne se vérifie pas seulement dans la maquette" />
    <published>2026-07-30T09:00:00+02:00</published>
    <updated>2026-07-30T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/web/2026/07/30/accessibilite-ne-se-verifie-pas-dans-la-maquette/</id>
    <content type="html" xml:base="https://sxnlabs.com/web/2026/07/30/accessibilite-ne-se-verifie-pas-dans-la-maquette/">&lt;p&gt;Une charte graphique peut être magnifique sur le papier et produire des écrans qu’une partie de vos utilisateurs n’arrive pas à lire. Ça m’est arrivé récemment, sur une interface que je développe : des centaines de textes trop pâles sur leur fond, dont le libellé du bouton principal et la barre d’onglets, sur une trentaine d’écrans. Aucune revue de design ne l’avait vu.&lt;/p&gt;

&lt;p&gt;Je ne parle ici que d’un morceau de l’accessibilité : le contraste des textes et des indicateurs. Il est visible, mesurable, et pourtant étonnamment facile à rater.&lt;/p&gt;

&lt;figure&gt;
  &lt;img src=&quot;/images/posts/accessibilite-maquette/maquette-vs-realite.fr.svg&quot; alt=&quot;Le même écran présenté deux fois : à gauche dans la maquette, contrasté et lisible ; à droite en plein soleil, délavé, le bouton principal et la barre d&#39;onglets presque invisibles&quot; /&gt;
  &lt;figcaption&gt;À gauche, l&#39;écran validé en revue. À droite, le même écran dehors, celui que l&#39;utilisateur a vraiment sous les yeux.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;ce-que-la-maquette-ne-montre-pas&quot;&gt;Ce que la maquette ne montre pas&lt;/h2&gt;

&lt;p&gt;Une revue regarde une intention, sur un bel écran, dans de bonnes conditions. La vraie vie, c’est aussi le même écran en plein soleil, sur un téléphone d’entrée de gamme, ou lu par quelqu’un de soixante ans. Un texte gris clair sur fond coloré peut sembler « stylé » dans le fichier de design et devenir invisible dehors.&lt;/p&gt;

&lt;p&gt;Dans cette interface, beaucoup de textes sont posés sur un dégradé, une transparence ou un mélange de teintes. La couleur à juger est celle obtenue une fois les couches composées, dans chaque thème et chaque état, pas seulement celle inscrite dans le fichier. La maquette peut et doit éliminer une partie des erreurs. Elle ne peut pas, à elle seule, prouver que l’implémentation finale reste lisible partout.&lt;/p&gt;

&lt;figure&gt;
  &lt;img src=&quot;/images/posts/accessibilite-maquette/couleur-declaree-vs-vue.fr.svg&quot; alt=&quot;À gauche, un aplat orange portant la couleur déclarée dans le code ; à droite, le même libellé blanc posé sur le dégradé réellement peint, dont l&#39;extrémité claire tombe à 1,5:1&quot; /&gt;
  &lt;figcaption&gt;La couleur écrite dans le fichier n&#39;est qu&#39;un aplat. Ce qui est peint, c&#39;est un dégradé, et le texte blanc y descend jusqu&#39;à 1,5:1 là où AA en demande 4,5.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;ce-quun-score-vert-ne-prouve-pas&quot;&gt;Ce qu’un score vert ne prouve pas&lt;/h2&gt;

&lt;p&gt;Le réflexe suivant consiste à lancer Lighthouse, obtenir un joli score vert et cocher la case. Mais un score vert ne prouve pas que chaque texte est lisible. Il signifie que les contrôles automatisables n’ont pas détecté de problème. Les dégradés, les images de fond et certaines superpositions restent difficiles à analyser : ils demandent une vérification dédiée.&lt;/p&gt;

&lt;p&gt;Le piège le plus instructif venait de notre propre contrôle. Pour juger un dégradé, il en calculait la couleur moyenne. Une plaque qui va du sombre au clair donnait donc une moyenne rassurante, une couleur peinte nulle part sur l’écran. Le test passait au vert sur une fiction.&lt;/p&gt;

&lt;figure&gt;
  &lt;img src=&quot;/images/posts/accessibilite-maquette/moyenne-vs-pire-point.fr.svg&quot; alt=&quot;Un dégradé du violet foncé au corail clair : la moyenne des deux extrémités atteint 4,51:1 et passe le test, alors que le point le plus clair tombe à 3,24:1 et échoue&quot; /&gt;
  &lt;figcaption&gt;Le même dégradé jugé de deux façons. Sur la moyenne, le contrôle passe au vert. Sur l&#39;endroit le plus défavorable, il échoue.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Un contrôle qui affiche vert sur une couleur qui n’existe pas vend de la tranquillité sans rien garantir. Nous l’avons corrigé pour qu’il juge l’endroit le plus défavorable du dégradé, pas sa moyenne.&lt;/p&gt;

&lt;h2 id=&quot;pourquoi-cétait-un-problème-de-produit&quot;&gt;Pourquoi c’était un problème de produit&lt;/h2&gt;

&lt;p&gt;Les pires cas n’étaient pas des détails décoratifs. C’était le bouton sur lequel tout le monde clique, la barre de navigation, et le petit cadre qui indique où on se trouve quand on navigue au clavier. Les éléments que tout le monde touche, à peine visibles pour une partie des gens.&lt;/p&gt;

&lt;p&gt;Un texte trop pâle, ce n’est pas « un peu moins joli ». C’est un utilisateur qui ne trouve pas le bouton, qui abandonne, qui appelle le support. Sur un produit grand public, la lisibilité est un chiffre de conversion avant d’être une case de conformité.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-nous-avons-corrigé-et-ce-qui-reste&quot;&gt;Ce que nous avons corrigé, et ce qui reste&lt;/h2&gt;

&lt;p&gt;Nous avons combiné les trois niveaux qui manquaient : la revue de design, le contrôle automatique et l’inspection de l’interface réellement rendue dans le navigateur. Aucun ne suffit seul. Ensemble, ils permettent de trouver les erreurs évidentes, les erreurs d’implémentation et les cas que l’automatisation ne sait pas trancher.&lt;/p&gt;

&lt;p&gt;Le point qui compte pour un dirigeant, c’est qu’aucune couleur de marque n’a bougé. Ce qui a changé, c’est l’encre posée dessus. Chaque type de fond a désormais sa couleur de texte dédiée, décidée une fois dans le système de design plutôt que choisie à l’œil écran par écran. Nous sommes passés de centaines de cas insuffisants à une poignée.&lt;/p&gt;

&lt;p&gt;Cette poignée est constituée de textes blancs sur le dégradé de marque, là où aucune couleur de texte unique ne respecte le seuil sur toute la surface. Les connaître ne les rend pas conformes. Ce sont des écarts résiduels qu’il reste à traiter en faisant évoluer la composition, par exemple avec un fond local ou un placement différent, sans nécessairement toucher à la palette.&lt;/p&gt;

&lt;h2 id=&quot;un-garde-fou-pas-un-audit&quot;&gt;Un garde-fou, pas un audit&lt;/h2&gt;

&lt;p&gt;Le garde-fou tient dans un petit test qui tourne en quelques secondes à chaque modification. Il ne remplace pas un audit d’accessibilité, et ne couvre même pas toute l’accessibilité. Il fait une chose plus étroite et très utile. Il empêche une nouvelle couleur, un composant refait ou un écran ajouté de repasser sous la barre sans que personne s’en aperçoive.&lt;/p&gt;

&lt;h2 id=&quot;la-bonne-question-nest-pas--quel-est-notre-score--&quot;&gt;La bonne question n’est pas « quel est notre score ? »&lt;/h2&gt;

&lt;p&gt;L’accessibilité n’est pas une faveur rendue à une minorité. Elle rend le produit utilisable par des gens qui vieillissent, qui lisent au soleil ou qui ont simplement une vue moyenne : tout le monde, un jour ou l’autre.&lt;/p&gt;

&lt;p&gt;Depuis juin 2025, l’&lt;a href=&quot;https://commission.europa.eu/strategy-and-policy/policies/justice-and-fundamental-rights/disability/european-accessibility-act-eaa_fr&quot;&gt;European Accessibility Act&lt;/a&gt; impose aussi des exigences d’accessibilité à des catégories précises de produits et de services, notamment le commerce électronique, la banque, les transports et les communications électroniques. Mais la conformité n’est pas la meilleure raison de s’en occuper, et ce test de contraste ne suffit évidemment pas à l’établir.&lt;/p&gt;

&lt;p&gt;Pour un dirigeant, les questions utiles sont plus simples : combien de nos utilisateurs arrivent réellement à lire nos écrans ? Qu’est-ce qui nous empêche de redevenir illisibles le mois prochain ? Si la réponse est « la vigilance de l’équipe », c’est qu’il n’y a pas de réponse.&lt;/p&gt;

&lt;p&gt;Dans ce cas précis, un premier diagnostic et le garde-fou ont demandé une demi-journée. Si vous avez un produit grand public et que personne n’a jamais vérifié le contraste de l’interface réellement livrée, nous pouvons regarder cela ensemble.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="web" />
    <category term="Accessibilité" />
    <category term="Design system" />
    <category term="Tests" />
    <category term="Front-end" />
    <summary type="html">Un test vert, une couleur qui n&#39;existait nulle part et trente écrans trop pâles : comment vérifier le contraste réel et empêcher les régressions.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/posts/accessibilite-maquette/apercu-social.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Quand l&#39;IA devient votre point de défaillance unique</title>
    <link href="https://sxnlabs.com/ai/2026/07/27/ia-point-de-defaillance-unique/" rel="alternate" type="text/html" title="Quand l&#39;IA devient votre point de défaillance unique" />
    <published>2026-07-27T09:00:00+02:00</published>
    <updated>2026-07-27T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/ai/2026/07/27/ia-point-de-defaillance-unique/</id>
    <content type="html" xml:base="https://sxnlabs.com/ai/2026/07/27/ia-point-de-defaillance-unique/">&lt;p&gt;Récemment, mon tableau de bord de supervision est resté figé pendant trois jours. Les erreurs applicatives de mes projets y sont affichées en continu, agrégées toutes les heures. Pendant trois jours, la tuile a montré des chiffres parfaitement crédibles. Ils dataient de trois jours plus tôt.&lt;/p&gt;

&lt;p&gt;Le service de monitoring, lui, n’avait jamais cessé de répondre. Les données étaient là, disponibles, à un appel d’API de distance. Ce qui était tombé, c’est le morceau que j’avais glissé au milieu du tuyau : une session d’IA.&lt;/p&gt;

&lt;h2 id=&quot;une-panne-silencieuse-la-pire-catégorie&quot;&gt;Une panne silencieuse, la pire catégorie&lt;/h2&gt;

&lt;p&gt;Ce qui rend l’histoire désagréable, c’est que la panne ne ressemblait pas à une panne.&lt;/p&gt;

&lt;p&gt;Un agent qui plante affiche une erreur, remonte une alerte, se voit. Ici, l’agent horaire échouait proprement, sans rien écrire, et la page continuait de servir le dernier état connu avec sa mise en forme habituelle. Rien ne clignotait. J’ai découvert le problème en cherchant autre chose.&lt;/p&gt;

&lt;p&gt;La cause était banale. L’authentification de l’outil en ligne de commande qui portait la session avait expiré. Pas de quota dépassé, pas de modèle indisponible, pas de mauvaise réponse. Juste un jeton périmé sur un composant dont je n’avais jamais considéré qu’il faisait partie de ma chaîne de collecte.&lt;/p&gt;

&lt;h2 id=&quot;pourquoi-lia-était-là-au-départ&quot;&gt;Pourquoi l’IA était là au départ&lt;/h2&gt;

&lt;p&gt;Par facilité, et c’est le point intéressant.&lt;/p&gt;

&lt;p&gt;L’accès au monitoring existait déjà sous forme d’outil branché à mon assistant. Écrire un prompt du type « récupère les erreurs non résolues, classe-les, écris la synthèse » prend dix minutes et fonctionne du premier coup. Écrire le client d’API équivalent, gérer la pagination, les champs, les cas limites, ça prend une demi-journée et ça ne produit aucune démo impressionnante.&lt;/p&gt;

&lt;p&gt;Le choix était donc rationnel à l’instant où je l’ai fait. Il l’était beaucoup moins un mois plus tard, quand ce raccourci était devenu un maillon de production sur lequel reposait la seule vue que j’avais de la santé de mes applications.&lt;/p&gt;

&lt;p&gt;C’est un schéma que je vois beaucoup en ce moment dans les entreprises qui « mettent de l’IA » dans leurs process. Le prototype est spectaculaire, la mise en production est indolore, et personne ne repasse derrière pour se demander ce qui se passe le jour où le composant le plus récent et le moins déterministe de la chaîne ne répond plus.&lt;/p&gt;

&lt;h2 id=&quot;le-correctif-deux-cents-lignes-ennuyeuses&quot;&gt;Le correctif, deux cents lignes ennuyeuses&lt;/h2&gt;

&lt;p&gt;Le remplacement fait environ deux cents lignes de Python. Il lit l’API REST du service de monitoring avec un jeton dédié, filtre les projets qui ne me concernent pas, applique une classification par mots-clés et écrit le résultat. Aucune IA sur le trajet.&lt;/p&gt;

&lt;p&gt;Le modèle n’a pas disparu pour autant, il a changé de place. Le tri fin, la formulation d’un diagnostic, la décision de créer ou non une tâche : ça reste une couche au-dessus, qui peut échouer sans conséquence. Si elle ne tourne pas, je perds du commentaire. Avant, je perdais la donnée.&lt;/p&gt;

&lt;p&gt;La différence tient en une phrase : la vérité affichée sur mon tableau de bord ne dépend plus de l’état d’authentification d’un assistant.&lt;/p&gt;

&lt;h2 id=&quot;ce-nétait-pas-le-premier-avertissement&quot;&gt;Ce n’était pas le premier avertissement&lt;/h2&gt;

&lt;p&gt;Quelques semaines plus tôt, l’agent qui surveille les autres agents s’était mis à m’envoyer des alertes de panne pour des services qui allaient très bien. La raison est qu’il calculait lui-même l’horodatage courant, et le modèle se trompait de journée dans certains créneaux horaires. Huit fausses alertes avant que je comprenne.&lt;/p&gt;

&lt;p&gt;Le correctif a été de lui interdire de produire une date. Une date se demande au système d’exploitation, elle ne se déduit pas.&lt;/p&gt;

&lt;p&gt;Là encore, la leçon n’est pas « l’IA est peu fiable ». Elle est plus précise. Un modèle de langage est non déterministe par construction, et c’est exactement ce qu’on lui demande quand on veut de la reformulation, du résumé ou de l’arbitrage. Le problème n’apparaît que si on lui confie en plus des tâches où le non déterminisme est un défaut : compter, dater, additionner, aller chercher une valeur qui existe déjà quelque part.&lt;/p&gt;

&lt;h2 id=&quot;la-règle-que-japplique-maintenant&quot;&gt;La règle que j’applique maintenant&lt;/h2&gt;

&lt;p&gt;Mon système personnel compte aujourd’hui quarante-huit agents actifs. Vingt-neuf d’entre eux ne contiennent aucune IA. Ce sont des scripts. Ils lisent une API, calculent, écrivent un fichier, envoient une notification. Ils tournent à l’heure, ils coûtent zéro, et leur mode de défaillance est lisible.&lt;/p&gt;

&lt;p&gt;Les dix-neuf autres utilisent un modèle parce qu’ils font quelque chose qu’un script ne sait pas faire : lire une conversation client et en sortir une intention, rédiger un brouillon, hiérarchiser des signaux hétérogènes, proposer trois options et en recommander une.&lt;/p&gt;

&lt;p&gt;La frontière que je trace est là. L’IA au-dessus du système, pour le jugement. Jamais dedans, sur le chemin d’acquisition ou de calcul de la donnée.&lt;/p&gt;

&lt;h2 id=&quot;la-question-à-poser-avant-den-mettre-partout&quot;&gt;La question à poser avant d’en mettre partout&lt;/h2&gt;

&lt;p&gt;Pour un dirigeant qui évalue une automatisation avec de l’IA dedans, la question utile va plus loin que « est-ce que ça marche en démo ». Elle tient en deux temps.&lt;/p&gt;

&lt;p&gt;Si le modèle est indisponible pendant trois jours, qu’est-ce qui s’arrête exactement ? Et est-ce que quelqu’un le voit, ou est-ce que l’écran continue d’afficher des chiffres qui ont l’air normaux ?&lt;/p&gt;

&lt;p&gt;Si la réponse à la seconde partie est floue, le problème tient à la dépendance plutôt qu’à l’IA. On l’a ajoutée sans lui appliquer les exigences qu’on applique à toutes les autres : détection de panne, mode dégradé explicite, et une hypothèse claire sur ce qui se passe quand elle n’est pas là.&lt;/p&gt;

&lt;p&gt;Une automatisation qui ne sait pas dire qu’elle est en panne n’est pas une automatisation, c’est un affichage.&lt;/p&gt;

&lt;p&gt;Si vous avez un process où de l’IA s’est installée sans qu’on ait vraiment tracé cette frontière, on peut regarder ensemble ce qui mérite d’y rester et ce qui gagnerait à redevenir un script.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="ai" />
    <category term="IA" />
    <category term="Automatisation" />
    <category term="Fiabilité" />
    <category term="Agents" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-07-27-ia-point-de-defaillance-unique.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Facturation électronique 2026 : le vrai chantier n&#39;est pas le format</title>
    <link href="https://sxnlabs.com/opensource/2026/07/23/facturation-electronique-2026-vrai-chantier/" rel="alternate" type="text/html" title="Facturation électronique 2026 : le vrai chantier n&#39;est pas le format" />
    <published>2026-07-23T09:00:00+02:00</published>
    <updated>2026-07-23T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/opensource/2026/07/23/facturation-electronique-2026-vrai-chantier/</id>
    <content type="html" xml:base="https://sxnlabs.com/opensource/2026/07/23/facturation-electronique-2026-vrai-chantier/">&lt;p&gt;La réforme de la facturation électronique arrive, et la question que tout le monde se pose est la mauvaise. Ce n’est pas « quel format ? » ni « quelle plateforme ? ». C’est : est-ce que mon logiciel sait déjà produire une facture électronique propre, cas limites compris ?&lt;/p&gt;

&lt;p&gt;Rappel en 30 secondes. Toutes les entreprises assujetties à la TVA en France sont concernées, même les TPE. La réception devient obligatoire dès septembre 2026 ; l’émission suit en septembre 2027 pour les TPE et PME, dès 2026 pour les grandes entreprises et les ETI. En face, une facture non conforme c’est un flux rejeté à l’entrée : retard de paiement, pénalité, risque direct sur la trésorerie.&lt;/p&gt;

&lt;p&gt;Générer un Factur-X, techniquement, c’est quelques lignes. Le vrai travail est ailleurs : les avoirs qui doivent référencer la bonne facture, les validateurs SIREN, SIRET, TVA et IBAN, le rapprochement métier, le branchement à une plateforme agréée ou à Chorus Pro. C’est là que la plupart des chaînes existantes calent, parce que le papier laissait passer ce que le XML structuré refuse.&lt;/p&gt;

&lt;p&gt;C’est pour ça que j’ai écrit &lt;code class=&quot;highlighter-rouge&quot;&gt;einvoicing&lt;/code&gt; et &lt;code class=&quot;highlighter-rouge&quot;&gt;einvoicing-connect&lt;/code&gt;, deux gems open source publiées sur RubyGems et déjà utilisées en production. Génération Factur-X PDF/A-3 conforme EN 16931, validateurs intégrés, client Chorus Pro et PPF prêt à brancher, sans SaaS tiers ni dépendance externe. Je les intègre dans un SI Rails existant en quelques jours.&lt;/p&gt;

&lt;p&gt;Pour savoir où vous en êtes, j’ai monté une page dédiée avec un diagnostic de 30 minutes pendant lequel je regarde votre stack, votre volumétrie et votre plateforme cible, et vous repartez avec un plan d’intégration concret et un devis ferme.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;/facturation-electronique-2026/&quot;&gt;Voir la page facturation électronique 2026&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Et pour vérifier qu’un PDF est déjà conforme, &lt;a href=&quot;/verifier-facture-electronique/&quot;&gt;un validateur Factur-X est en libre accès&lt;/a&gt;, analyse 100 % locale, sans upload.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opensource" />
    <category term="E-invoicing" />
    <category term="Factur-X" />
    <category term="Chorus Pro" />
    <category term="Conformité" />
    <category term="Rails" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-07-23-facturation-electronique-2026-vrai-chantier.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Ma haute disponibilité Postgres n&#39;en était pas une, autopsie d&#39;une panne</title>
    <link href="https://sxnlabs.com/opinion/2026/07/22/panne-postgres-ha-illusoire-scaleway/" rel="alternate" type="text/html" title="Ma haute disponibilité Postgres n&#39;en était pas une, autopsie d&#39;une panne" />
    <published>2026-07-22T18:00:00+02:00</published>
    <updated>2026-07-22T18:00:00+02:00</updated>
    <id>https://sxnlabs.com/opinion/2026/07/22/panne-postgres-ha-illusoire-scaleway/</id>
    <content type="html" xml:base="https://sxnlabs.com/opinion/2026/07/22/panne-postgres-ha-illusoire-scaleway/">&lt;p&gt;Hier soir, l’ensemble de mes services internes et les logiciels que j’héberge pour mes clients sont devenus injoignables pendant plusieurs heures. Une base Postgres saturée, une haute disponibilité qui n’a pas joué son rôle, et un incident Scaleway par-dessus. Voici l’autopsie, sans filtre, parce qu’une panne qu’on raconte honnêtement vaut mieux qu’une panne que l’on maquille.&lt;/p&gt;

&lt;h2 id=&quot;le-déclencheur-ou-plutôt-les-déclencheurs&quot;&gt;Le déclencheur, ou plutôt les déclencheurs&lt;/h2&gt;

&lt;p&gt;À 22h15 (heure de Paris), Scaleway perd un nœud de son cluster de block storage en fr-par-1 (incident &lt;code class=&quot;highlighter-rouge&quot;&gt;bsp2y5fysy9w&lt;/code&gt;). Côté infra, le résultat est immédiat : des machines virtuelles bloquées, et surtout des bases managées gelées sur un stockage devenu inaccessible.&lt;/p&gt;

&lt;p&gt;Au même moment, de mon côté, j’avais lancé un batch de jobs d’enrichissement de contacts sur Argos, mon CRM. Ce genre de batch ouvre beaucoup de connexions Postgres, et vite. Est-ce lui qui a saturé le pool, ou la base souffrait-elle déjà du storage Scaleway ? Je n’ai pas de réponse nette, et c’est exactement le piège des incidents corrélés : deux causes plausibles qui tombent dans la même fenêtre, et un doute qui ne se lève qu’après coup. Ce que je sais, c’est que les deux se sont nourris l’un l’autre. Une base qui répond mal, des connexions qui s’accumulent au lieu de se libérer, un batch qui continue d’en réclamer. Le pool atteint sa limite de connexions, et à partir de là, plus aucune application ne peut en ouvrir une seule. Tout tombe en cascade.&lt;/p&gt;

&lt;h2 id=&quot;le-vrai-problème--une-ha-qui-nen-était-pas-une&quot;&gt;Le vrai problème : une HA qui n’en était pas une&lt;/h2&gt;

&lt;p&gt;Ma base tournait en haute disponibilité, deux nœuds. Rassurant sur le papier. Sauf que les deux nœuds vivaient dans le même datacenter, fr-par-1. Quand l’étage block storage de fr-par-1 est tombé, mes deux répliques sont parties ensemble, à la même seconde. C’est la leçon que je retiens le plus : une redondance ne protège que contre ce qu’elle isole. La mienne couvrait la panne d’un process ou d’une machine, pas celle d’une zone entière. J’avais souscrit une assurance contre un risque, pas contre tous.&lt;/p&gt;

&lt;p&gt;Pire, impossible de redémarrer la base. Le moteur Postgres managé restait accroché au stockage défaillant. Je pouvais tuer les connexions zombies et relancer mes applications autant que je voulais, le rétablissement final ne dépendait plus de moi : il était suspendu à la réparation de Scaleway. Se retrouver spectateur de sa propre panne, sans aucun levier, c’est le moment le plus désagréable de la soirée.&lt;/p&gt;

&lt;h2 id=&quot;la-sortie-de-crise&quot;&gt;La sortie de crise&lt;/h2&gt;

&lt;p&gt;La situation s’est débloquée dans la nuit, en combinant trois choses. J’ai coupé les connexions restées ouvertes côté Postgres, j’ai redémarré les applications pour repartir sur des pools propres, et Scaleway a fini par réparer le nœud de storage. Ce dernier point était le facteur décisif. Sans lui, le reste ne servait à rien. Mes deux premières actions n’ont fait que préparer le terrain pour que tout reparte proprement une fois l’infra revenue.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-jen-tire-et-ce-que-jai-déjà-fait&quot;&gt;Ce que j’en tire, et ce que j’ai déjà fait&lt;/h2&gt;

&lt;p&gt;Un bon incident, c’est celui qui vous fait enfin faire ce que vous repoussiez. J’ai attaqué trois chantiers dans la foulée.&lt;/p&gt;

&lt;p&gt;PgBouncer devant Postgres, d’abord. Un pooler de connexions que je voulais poser depuis longtemps sans jamais m’y mettre. Il borne le nombre de connexions et empêche un batch d’affamer toutes les autres applications. C’est en place.&lt;/p&gt;

&lt;p&gt;Un failover en lecture seule dans une autre zone, ensuite. Mes deux nœuds de HA dans le même datacenter, c’était le cœur du problème. J’ai ajouté une réplique lecture seule ailleurs, pour qu’une zone Scaleway qui tombe ne m’emporte plus tout d’un bloc.&lt;/p&gt;

&lt;p&gt;De l’observabilité sur les connexions, enfin. Surveiller &lt;code class=&quot;highlighter-rouge&quot;&gt;pg_stat_activity&lt;/code&gt; et le taux de remplissage du pool, pour voir la saturation grimper et déclencher une alerte avant qu’elle ne tue tout, au lieu de la découvrir quand le service est déjà par terre.&lt;/p&gt;

&lt;h2 id=&quot;la-leçon-en-une-phrase&quot;&gt;La leçon, en une phrase&lt;/h2&gt;

&lt;p&gt;Une dépendance d’infrastructure, aussi solide soit-elle sur le papier, reste un point de défaillance unique tant qu’on ne l’a pas explicitement isolée. Et une redondance qu’on n’a jamais testée contre le bon mode de panne n’est qu’une redondance de façade.&lt;/p&gt;

&lt;p&gt;Si vous avez une infra dont vous répétez qu’elle est « en haute dispo » sans avoir vérifié contre quel type de panne exactement, on peut regarder ça ensemble, plutôt que de le découvrir un mardi soir.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opinion" />
    <category term="PostgreSQL" />
    <category term="Scaleway" />
    <category term="Haute disponibilité" />
    <category term="Infrastructure" />
    <category term="Postmortem" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-07-22-panne-postgres-ha-illusoire-scaleway.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Combien de cash on me doit, en tout, là, maintenant ?</title>
    <link href="https://sxnlabs.com/side-project/2026/07/13/suivi-tresorerie-freelance-cash-a-encaisser/" rel="alternate" type="text/html" title="Combien de cash on me doit, en tout, là, maintenant ?" />
    <published>2026-07-13T18:00:00+02:00</published>
    <updated>2026-07-13T18:00:00+02:00</updated>
    <id>https://sxnlabs.com/side-project/2026/07/13/suivi-tresorerie-freelance-cash-a-encaisser/</id>
    <content type="html" xml:base="https://sxnlabs.com/side-project/2026/07/13/suivi-tresorerie-freelance-cash-a-encaisser/">&lt;blockquote&gt;
  &lt;p&gt;Toutes les données financières et tous les noms de clients de cet article sont fictifs. Les captures montrent de vrais écrans, avec un jeu de données inventé.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Quand on est seul aux commandes d’une boîte, il y a une question qui revient plus souvent que toutes les autres. Non pas “combien j’ai facturé le mois dernier”, pas “quel est mon chiffre d’affaires annuel”. La vraie question, celle qui décide de tout, c’est : &lt;strong&gt;combien de cash on me doit, en tout, et que je vais encaisser ?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pas dans une fenêtre de 30 ou 90 jours. En tout. Tout ce qui est facturé et pas encore rentré, sans limite de temps, avec ce qui est en retard clairement signalé.&lt;/p&gt;

&lt;p&gt;Et le problème, c’est que personne ne me répondait. Mon logiciel de compta me disait ce qui était facturé. Mon CRM ce qui était dans le pipe. Mon compte en banque ce qui était déjà là. Trois vérités partielles, dans trois outils différents, qu’il fallait recroiser à la main dans un tableur le dimanche soir. Autant dire jamais.&lt;/p&gt;

&lt;p&gt;Alors j’ai recollé les morceaux à la source, dans Argos.&lt;/p&gt;

&lt;h2 id=&quot;argos-de-crm-à-cockpit-de-trésorerie&quot;&gt;Argos, de CRM à cockpit de trésorerie&lt;/h2&gt;

&lt;p&gt;Argos, c’est le CRM que j’ai construit pour SXN Labs. À l’origine il gérait les contacts, les deals, les devis. Le dernier chantier a consisté à lui greffer une brique de suivi de trésorerie, avec une règle que je me suis fixée : afficher des faits, pas des prédictions. Je ne voulais pas d’un modèle qui devine mon avenir. Je voulais la vérité à l’instant T, celle que je peux vérifier ligne à ligne.&lt;/p&gt;

&lt;p&gt;Le résultat, c’est un tableau de bord dont l’état par défaut est une phrase que j’adore lire : &lt;strong&gt;“Nothing needs your attention.”&lt;/strong&gt; Quand quelque chose cloche, il le remonte. Quand tout va bien, il le dit, et il se tait. En tête d’écran, quelques chiffres, tous factuels, tous à jour, sans que j’aie rien à saisir.&lt;/p&gt;

&lt;figure&gt;
  &lt;img src=&quot;/images/posts/argos/argos-dashboard.fr.png&quot; alt=&quot;Tableau de bord de trésorerie d&#39;Argos : cash à encaisser, HT, TVA et impayés (données fictives)&quot; /&gt;
  &lt;figcaption&gt;Le tableau de bord Cash d&#39;Argos, en un coup d&#39;oeil (données fictives).&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Sous ce calme apparent, il y a trois petits défis techniques que je trouve intéressants.&lt;/p&gt;

&lt;h2 id=&quot;tout-le-cash-en-attente-dun-coup&quot;&gt;Tout le cash en attente, d’un coup&lt;/h2&gt;

&lt;p&gt;Le chiffre central, c’est le “à encaisser”. La somme de toutes les factures émises et pas encore payées. Pas de fenêtre glissante, pas d’horizon arbitraire. Le total, point.&lt;/p&gt;

&lt;p&gt;Sauf qu’un montant brut ne suffit pas. Ce que je veux voir en un coup d’oeil, c’est le HT (ce qui m’appartient vraiment), la part de TVA (que je ne fais que garder pour l’État), et surtout ce qui est &lt;strong&gt;en retard&lt;/strong&gt;. Un client qui doit 8 400 € depuis dix jours, ce n’est pas la même information qu’un client qui a jusqu’à la fin du mois pour me payer. Le premier appelle une relance, le second non.&lt;/p&gt;

&lt;p&gt;Ce croisement HT / TTC / retard, Argos le calcule à partir des factures Pennylane. Et c’est là que le choix de Pennylane comme banque &lt;em&gt;et&lt;/em&gt; logiciel de compta paie. Pas de rapprochement bancaire à faire, les paiements sont rapprochés nativement des factures, les transactions arrivent déjà catégorisées. Je consomme la donnée telle quelle, je ne reclassifie rien à la main.&lt;/p&gt;

&lt;h2 id=&quot;deux-comptes-une-seule-trésorerie&quot;&gt;Deux comptes, une seule trésorerie&lt;/h2&gt;

&lt;p&gt;Ma trésorerie vit à deux endroits. Pennylane pour la banque au quotidien. Spiko pour la trésorerie dormante, placée sur des fonds monétaires tokenisés qui rapportent un peu chaque jour.&lt;/p&gt;

&lt;p&gt;La trésorerie totale, c’est donc la somme des deux poches. Comme elles sont physiquement distinctes, un compte en banque d’un côté, des parts de fonds de l’autre, il n’y a aucun risque de compter deux fois le même euro. Et si l’une des deux sources tombe, le système retombe proprement sur l’autre plutôt que d’afficher une trésorerie fantôme.&lt;/p&gt;

&lt;p&gt;Petite anecdote pour ceux qui ont déjà intégré une API : Spiko renvoie un joli &lt;strong&gt;403 Cloudflare&lt;/strong&gt; si tu n’envoies pas un User-Agent de navigateur. Le classique “ton &lt;code class=&quot;highlighter-rouge&quot;&gt;urllib&lt;/code&gt; est blacklisté par défaut”. Cinq minutes de perplexité, un header, et on passe à la suite.&lt;/p&gt;

&lt;h2 id=&quot;et-tout-le-reste-au-même-endroit&quot;&gt;Et tout le reste, au même endroit&lt;/h2&gt;

&lt;p&gt;Une fois le cash à encaisser et la trésorerie en place, j’ai ajouté les autres faits qui pèsent sur une petite boîte : les revenus récurrents mensuels, ce qui a été réellement encaissé ce mois-ci, et une estimation du dividende que je pourrais me verser, net après impôt. Toujours la même logique : des chiffres constatés, pas des promesses.&lt;/p&gt;

&lt;p&gt;Et parce qu’Argos n’est pas qu’un outil financier, le même écran agrège aussi l’opérationnel : mes applications déployées sont-elles en bonne santé, combien de serveurs sont suivis, quels déploiements ont eu lieu aujourd’hui, ma boîte mail est-elle à jour, mes frais kilométriques sont-ils saisis. Finances, ops et admin, un seul coup d’oeil. Le tout couronné par ce fameux “rien ne requiert ton attention” quand la checklist est verte.&lt;/p&gt;

&lt;h2 id=&quot;le-twist--moins-dinterface-pas-plus&quot;&gt;Le twist : moins d’interface, pas plus&lt;/h2&gt;

&lt;p&gt;Voici la partie contre-intuitive.&lt;/p&gt;

&lt;p&gt;La tendance naturelle, quand on ajoute des données à un outil, c’est d’empiler les écrans, les graphiques, les filtres. J’ai fait l’inverse. J’ai &lt;strong&gt;réduit la surface de l’interface d’Argos au maximum&lt;/strong&gt;, et je le pilote désormais presque exclusivement via MCP.&lt;/p&gt;

&lt;p&gt;Pour ceux qui ne connaissent pas, MCP (Model Context Protocol) permet d’exposer les fonctions d’une application comme des outils utilisables par un assistant. Concrètement, Argos expose des outils du type &lt;code class=&quot;highlighter-rouge&quot;&gt;list_deals&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;create_quote&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;get_metrics_summary&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;daily_briefing&lt;/code&gt;. Du coup, je ne clique quasiment plus. Je &lt;em&gt;parle&lt;/em&gt; à mon CRM. “Résume-moi la trésorerie”, “quels devis sont signés mais pas encore facturés”, “crée un deal pour ce prospect”. La réponse arrive, ou l’action se fait, sans que j’ouvre une seule page.&lt;/p&gt;

&lt;p&gt;Ce que j’ai compris en construisant ça, c’est que pour un outil que j’utilise seul, l’interface la plus efficace n’est pas une belle page web. C’est une bonne API, correctement décrite, qu’une IA peut manipuler à ma place. L’UI graphique devient l’exception, réservée aux moments où je veux vraiment &lt;em&gt;voir&lt;/em&gt; quelque chose.&lt;/p&gt;

&lt;p&gt;Un mot sur la confiance, parce que la question vient toujours : tout ce pipeline financier est en &lt;strong&gt;lecture seule&lt;/strong&gt;. Aucune création de facture, aucun virement, aucun mouvement d’argent automatisé. Le système lit, agrège, affiche. Il ne touche jamais à l’argent. C’est un capteur, pas une main.&lt;/p&gt;

&lt;h2 id=&quot;le-cash-sous-les-yeux-sans-le-chercher&quot;&gt;Le cash sous les yeux, sans le chercher&lt;/h2&gt;

&lt;p&gt;Restait un dernier détail, et c’est peut-être le plus agréable au quotidien.&lt;/p&gt;

&lt;p&gt;Un tableau de bord, aussi juste soit-il, ne sert à rien s’il est enterré dans un onglet que j’ouvre une fois par semaine. Alors je l’ai sorti de l’écran. J’ai posé un petit afficheur e-ink, un &lt;a href=&quot;https://trmnl.com/&quot;&gt;TRMNL&lt;/a&gt;, sur l’étagère de mon bureau. Il fait tourner l’écran Cash d’Argos (à signer, à facturer, à encaisser, la trésorerie, les impayés, l’encaissé du mois) en rotation avec d’autres écrans du CRM et des écrans plus généralistes : la météo, une photo, et le reste.&lt;/p&gt;

&lt;figure&gt;
  &lt;img src=&quot;/images/posts/argos/trmnl-eink.jpg&quot; alt=&quot;Écran e-ink TRMNL affichant le suivi de trésorerie d&#39;Argos sur un bureau&quot; /&gt;
  &lt;figcaption&gt;Le TRMNL sur mon bureau : la trésorerie, ambiante (données fictives).&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;L’effet est étonnamment fort. L’information financière n’est plus quelque chose que je vais &lt;em&gt;chercher&lt;/em&gt; : elle passe d’elle-même dans mon champ de vision plusieurs fois par jour, entre la météo et une photo, à côté du chapeau et des lunettes de soleil. Je lève les yeux, et régulièrement, je sais où j’en suis. Pas de notification, pas d’app à ouvrir. Juste un chiffre calme, mis à jour tout seul, en noir et blanc sur du papier électronique.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-je-retiens&quot;&gt;Ce que je retiens&lt;/h2&gt;

&lt;p&gt;En construisant ce truc pour moi, j’ai validé une conviction : les meilleurs outils qu’on peut fabriquer, ce sont souvent ceux qu’on utilise soi-même en premier, avec de vrais besoins et de vrais irritants. Le suivi de trésorerie d’Argos est né de ma question à moi, celle du cash qu’on me doit. Et cette exigence d’utilisateur, celle qui ne laisse rien passer, finit par infuser dans le produit que je propose à mes clients.&lt;/p&gt;

&lt;p&gt;Trois idées à retenir : recoller les données à la source plutôt que dans un tableur, afficher des faits vérifiables plutôt que des prédictions, et accepter que la meilleure interface, c’est parfois celle qui n’existe pas.&lt;/p&gt;

&lt;p&gt;La prochaine fois que vous vous demandez combien on vous doit vraiment, posez-vous une autre question : est-ce que votre outil vous répond en un coup d’oeil, ou est-ce que vous devez encore ouvrir un tableur le dimanche soir ?&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="side-project" />
    <category term="Cash flow" />
    <category term="CRM" />
    <category term="MCP" />
    <category term="Pennylane" />
    <category term="TRMNL" />
    <category term="Side project" />
    <summary type="html">Comment je suis toute ma trésorerie et le cash à encaisser d&#39;un coup d&#39;oeil dans Argos, mon CRM, sans limite de temps et en lecture seule.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/posts/argos/argos-dashboard.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Retirer Algolia d&#39;un logiciel de santé, la dépendance qui ne se justifiait plus</title>
    <link href="https://sxnlabs.com/opinion/2026/07/06/retirer-algolia-logiciel-sante-postgres-suffisait/" rel="alternate" type="text/html" title="Retirer Algolia d&#39;un logiciel de santé, la dépendance qui ne se justifiait plus" />
    <published>2026-07-06T18:00:00+02:00</published>
    <updated>2026-07-06T18:00:00+02:00</updated>
    <id>https://sxnlabs.com/opinion/2026/07/06/retirer-algolia-logiciel-sante-postgres-suffisait/</id>
    <content type="html" xml:base="https://sxnlabs.com/opinion/2026/07/06/retirer-algolia-logiciel-sante-postgres-suffisait/">&lt;p&gt;Sur ORIGAMI, le logiciel métier de santé que je développe pour SOS Médecins (&lt;a href=&quot;https://sxnlabs.com/clients/sante/sos-medecins-origami-modernisation/&quot;&gt;voir le case study&lt;/a&gt;), la recherche de patients tournait sur Algolia. Un service de recherche hébergé, rapide, tolérant aux fautes de frappe. La semaine dernière, je l’ai débranché entièrement. Le bilan de la modification tient en deux chiffres : 33 lignes ajoutées, 331 supprimées. Et côté utilisateur, personne n’a rien remarqué. C’est exactement le résultat que je visais.&lt;/p&gt;

&lt;h2 id=&quot;pourquoi-algolia-était-là&quot;&gt;Pourquoi Algolia était là&lt;/h2&gt;

&lt;p&gt;Le réflexe est connu. On a besoin d’une « vraie » recherche, instantanée, qui pardonne les fautes de frappe, qui remonte le bon patient même quand on tape trois lettres de travers. On regarde Postgres, on se dit que ce sera compliqué, et on branche un service spécialisé. Algolia fait très bien ce travail. En quelques heures, la recherche est fluide et l’affaire semble réglée.&lt;/p&gt;

&lt;p&gt;Sauf que « brancher Algolia » ne se résume jamais à une ligne. Dans le code, ça voulait dire une gem côté Ruby, un paquet npm côté front, un initializer à configurer, des clés d’API exposées au navigateur, un job qui réindexait les patients en parallèle de la base à chaque modification, et trois contrôleurs Stimulus qui parlaient directement au client Algolia. Une dépendance, ce n’est pas un bouton qu’on active : c’est une surface qu’on adopte, qu’on met à jour, qu’on surveille et qu’on facture tous les mois.&lt;/p&gt;

&lt;h2 id=&quot;le-vrai-besoin-une-fois-posé-à-plat&quot;&gt;Le vrai besoin, une fois posé à plat&lt;/h2&gt;

&lt;p&gt;La question que je ne m’étais pas reposée depuis longtemps : de quelle recherche ai-je réellement besoin, ici ?&lt;/p&gt;

&lt;p&gt;La réponse est modeste. On cherche des patients dans le périmètre d’une structure de soins. Le jeu de données se compte en centaines de milliers d’enregistrements, pas en dizaines de millions. Les champs sont simples : un nom, un prénom, quelques identifiants. Le besoin réel, c’est de retrouver « Dupont » quand on tape « dupon », et de le retrouver vite.&lt;/p&gt;

&lt;p&gt;Pour ça, Postgres suffit largement, y compris à cette échelle. L’extension &lt;code class=&quot;highlighter-rouge&quot;&gt;pg_trgm&lt;/code&gt; calcule une similarité par trigrammes : elle absorbe la faute de frappe et le préfixe incomplet sans qu’on écrive le moindre algorithme de distance à la main. Une table dédiée matérialise le document de recherche de chaque patient, un job l’alimente à la modification, et la recherche s’exécute dans la même base, dans la même transaction, sans aller-retour réseau vers un tiers. Ce backend interne existait déjà et tournait par défaut depuis quelques semaines. Algolia n’était plus qu’un second moteur qu’on trainait par prudence.&lt;/p&gt;

&lt;h2 id=&quot;le-vrai-argument--de-la-donnée-de-santé-qui-voyage&quot;&gt;Le vrai argument : de la donnée de santé qui voyage&lt;/h2&gt;

&lt;p&gt;Le &lt;abbr title=&quot;Keep It Simple, Stupid&quot;&gt;KISS&lt;/abbr&gt; est une bonne raison. Ce n’est pas la meilleure ici.&lt;/p&gt;

&lt;p&gt;Envoyer des noms de patients vers un index de recherche hébergé à l’étranger, c’est faire sortir de la donnée de santé de son infrastructure. Ça engage l’hébergement des données de santé, le RGPD, le principe de minimisation. Même quand tout est contractuellement carré, c’est une surface de gouvernance de plus : un endroit supplémentaire où de la donnée sensible transite, un sous-traitant de plus à auditer, une clause de plus à défendre le jour où quelqu’un pose la question.&lt;/p&gt;

&lt;p&gt;Retirer Algolia, ce n’est donc pas seulement supprimer une dépendance et une facture de 250 € par mois. C’est retirer une raison de s’inquiéter. La donnée patient ne quitte plus la base. Il n’y a plus d’index externe à sécuriser, plus de clés d’API à faire fuir depuis le front, plus de contrat de sous-traitance à surveiller pour une fonctionnalité qu’on savait déjà faire en interne.&lt;/p&gt;

&lt;h2 id=&quot;le-vrai-enjeu-de-la-bascule--que-personne-ne-sente-rien&quot;&gt;Le vrai enjeu de la bascule : que personne ne sente rien&lt;/h2&gt;

&lt;p&gt;Basculer d’un moteur de recherche à un autre sur un logiciel utilisé en production, ça ne se fait pas d’un coup de commutateur global. Le critère était clair. Côté utilisateur, les temps de réponse de la recherche devaient rester rigoureusement identiques. Pas « à peu près aussi rapide », identique. Une recherche patient qui traîne d’un dixième de seconde de plus, sur un poste de régulation, ça se remarque tout de suite.&lt;/p&gt;

&lt;p&gt;Pour valider ça sans risque, j’avais ajouté un booléen par utilisateur, &lt;code class=&quot;highlighter-rouge&quot;&gt;internal_patient_search&lt;/code&gt;, qui activait le nouveau moteur pour quelques comptes seulement. Un mécanisme d’activation sélectif, pas un drapeau oublié : le temps de faire tourner les deux backends côte à côte, de comparer les temps de réponse en conditions réelles, et de confirmer que le passage à Postgres ne se sentait nulle part.&lt;/p&gt;

&lt;p&gt;Une fois cette validation faite, le flag avait fait son travail. Je l’ai retiré en même temps qu’Algolia, dans la même passe : la colonne en base, la case dans les réglages, les paramètres autorisés du contrôleur, les libellés en deux langues et trois tests devenus sans objet. Un seul booléen touche déjà six endroits du code ; la discipline consiste moins à ne jamais en ajouter qu’à le démonter dès qu’il a fini de servir.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-ce-nest-pas&quot;&gt;Ce que ce n’est pas&lt;/h2&gt;

&lt;p&gt;Aucune charge contre Algolia ici. C’est un excellent produit, et il y a des contextes où je le rebrancherais sans hésiter : un gros catalogue e-commerce, une recherche grand public en façade, une tolérance à la faute de frappe à l’échelle de millions de documents. Le sujet n’est pas l’outil.&lt;/p&gt;

&lt;p&gt;Le sujet, c’est la question qu’on se pose avant de l’adopter. Non pas « quelle est la meilleure recherche ? », mais « de quelle recherche ai-je besoin, pour ce jeu de données, dans ce contexte ? ». Quand on prend le temps d’y répondre honnêtement, on évite souvent d’importer une dépendance, une facture récurrente, et, dans le cas de la santé, une donnée sensible qui n’avait aucune raison de voyager.&lt;/p&gt;

&lt;h2 id=&quot;la-simplicité-est-une-décision&quot;&gt;La simplicité est une décision&lt;/h2&gt;

&lt;p&gt;Absorber la complexité côté prestataire pour que le logiciel reste simple côté client, ça vaut aussi pour la complexité qu’on s’inflige à soi-même. Le meilleur code n’est pas celui qu’on écrit avec le plus d’élégance : c’est celui qu’on retire parce qu’on n’en a plus besoin, et qu’on n’aura plus jamais à maintenir. 331 lignes en moins, une dépendance de moins, une donnée de santé qui reste chez elle, et une interface strictement identique pour l’utilisateur, temps de réponse compris. C’est ça, un bon jour.&lt;/p&gt;

&lt;p&gt;Si vous avez sous le capot une dépendance externe qui vous coûte tous les mois sans que vous sachiez vraiment ce qu’elle vous apporte, on peut regarder ensemble ce qui mérite d’y rester et ce qui peut rentrer à la maison.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opinion" />
    <category term="Rails" />
    <category term="PostgreSQL" />
    <category term="Architecture" />
    <category term="KISS" />
    <category term="Souveraineté" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-07-06-retirer-algolia-logiciel-sante-postgres-suffisait.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">L&#39;IA n&#39;a pas supprimé mon métier, elle a déplacé mon plafond</title>
    <link href="https://sxnlabs.com/opinion/2026/06/22/ia-deplace-mon-plafond/" rel="alternate" type="text/html" title="L&#39;IA n&#39;a pas supprimé mon métier, elle a déplacé mon plafond" />
    <published>2026-06-22T11:00:00+02:00</published>
    <updated>2026-06-22T11:00:00+02:00</updated>
    <id>https://sxnlabs.com/opinion/2026/06/22/ia-deplace-mon-plafond/</id>
    <content type="html" xml:base="https://sxnlabs.com/opinion/2026/06/22/ia-deplace-mon-plafond/">&lt;p&gt;On me demande régulièrement si l’IA va remplacer les développeurs. La question est posée à l’envers. Sur mes projets, l’IA n’a supprimé aucune compétence. Elle a déplacé une contrainte que je tenais pour acquise depuis dix ans : mon propre temps.&lt;/p&gt;

&lt;h2 id=&quot;le-vrai-plafond-na-jamais-été-le-code&quot;&gt;Le vrai plafond n’a jamais été le code&lt;/h2&gt;

&lt;p&gt;Quand on développe seul ou à deux un logiciel métier sérieux, ce qui plafonne la production, c’est le nombre d’heures qu’un humain tient dans une semaine sans casser la qualité. Un éditeur de petite taille passe sa vie à arbitrer : cette semaine je branche telle intégration, donc je ne touche pas au module de facturation ; ce trimestre je fais passer une certification, donc la refonte mobile attend.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://simonwillison.net/2026/Jun/14/why-ai-hasnt-replaced-software-engineers/&quot;&gt;Simon Willison&lt;/a&gt; le résume bien dans un billet récent : si l’IA n’a pas provoqué de vague de licenciements de développeurs, c’est que coder n’est qu’une petite partie du métier. Sur 160 entreprises ayant déclaré des licenciements à New York en 2025, &lt;em&gt;« pas une seule n’a coché la case IA »&lt;/em&gt;. Décider quoi construire, spécifier, vérifier, assumer ce qu’on livre, comprendre le contexte métier, rien de tout ça ne s’automatise.&lt;/p&gt;

&lt;p&gt;Je suis d’accord. Mais j’en tire une conclusion un peu différente de la sienne. Si le code n’est qu’une petite partie du job, alors accélérer le code ne change pas la &lt;em&gt;nature&lt;/em&gt; du métier, ça change sa &lt;em&gt;capacité&lt;/em&gt;. Et pour une petite structure, la capacité, c’est presque tout.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-ça-change-concrètement&quot;&gt;Ce que ça change concrètement&lt;/h2&gt;

&lt;p&gt;Ma façon de travailler a bougé sur le scoping, le chiffrage et la conduite de projet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le scoping.&lt;/strong&gt; Avant, j’écartais d’instinct les fonctionnalités « correctes mais coûteuses » : le mode dégradé propre, le monitoring, les tests d’intégration sur un flux externe. Pas par paresse, par budget-temps. Aujourd’hui, le coût marginal de bien faire a baissé. Je branche le filet de sécurité parce que l’écrire ne me coûte plus une demi-journée.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le chiffrage.&lt;/strong&gt; Un forfait, c’est un pari sur le temps. Quand le plafond de production monte, le même périmètre se livre plus vite, ou un périmètre plus ambitieux tient dans la même enveloppe. Ça ne veut pas dire facturer moins ; ça veut dire livrer plus de valeur pour le prix, et passer plus vite à la commande suivante.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La conduite de projet.&lt;/strong&gt; Plus d’allers-retours avec le client, et plus tôt. Maquetter une variante en un après-midi au lieu d’une semaine, c’est pouvoir dire « regarde, voilà à quoi ça ressemble » avant de figer une décision. La vélocité ne sert pas à produire plus de lignes ; elle sert à raccourcir la boucle entre une idée métier et un truc qu’on peut toucher.&lt;/p&gt;

&lt;h2 id=&quot;ce-quelle-ne-change-pas-et-langoisse-qui-va-avec&quot;&gt;Ce qu’elle ne change pas (et l’angoisse qui va avec)&lt;/h2&gt;

&lt;p&gt;Il existe une autre lecture, plus sombre, qui circule beaucoup chez les développeurs. Un &lt;a href=&quot;https://human-in-the-loop.bearblog.dev/llms-are-eroding-my-software-engineering-career-and-i-dont-know-what-to-do/&quot;&gt;billet qui a tourné récemment&lt;/a&gt; raconte la même histoire de l’intérieur : dix ans à bâtir une expertise pointue (finance, debugging, architecture) pour la voir devenir &lt;em&gt;« promptable »&lt;/em&gt; en quelques semaines. L’angoisse n’est pas le chômage, c’est la commoditisation de la maîtrise.&lt;/p&gt;

&lt;p&gt;Je comprends le vertige. Mais je crois qu’il confond deux choses : &lt;em&gt;savoir faire&lt;/em&gt;, et &lt;em&gt;savoir quoi faire et en répondre&lt;/em&gt;. L’IA rend le premier abondant. Le second (décider, spécifier, vérifier, porter la responsabilité de ce qui part en production sur un logiciel sensible) reste rare, et reste à moi.&lt;/p&gt;

&lt;p&gt;Le métier ne disparaît pas. Il remonte d’un cran, vers moins de production et plus de jugement.&lt;/p&gt;

&lt;h2 id=&quot;pour-élargir-le-sujet&quot;&gt;Pour élargir le sujet&lt;/h2&gt;

&lt;p&gt;Je vais élargir, en assumant que c’est un avis et pas une démonstration.&lt;/p&gt;

&lt;p&gt;On parle beaucoup du problème démographique français : pyramide des âges qui s’inverse, départ des classes nombreuses, relève plus mince derrière. Le débat public se cristallise sur une seule variable, l’âge de départ. C’en est une parmi d’autres. Produire davantage par tête en est une autre, et l’outillage qui augmente la capacité d’un actif y participe directement.&lt;/p&gt;

&lt;p&gt;Je ne prétends pas que l’IA règle la démographie, ce serait exactement le genre de raccourci marketing que je passe mon temps à critiquer. Mais quand un même actif peut tenir un volume de production qui en demandait deux hier, ça pèse dans l’équation. C’est moins clivant que de déplacer un curseur d’âge, et probablement plus durable. À condition que les gains aillent à la valeur produite, pas à l’empilement d’outils pour l’outil.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;L’IA n’a pas remplacé mon métier. Elle a retiré la limite la plus bête qui pesait dessus : le fait qu’une journée fait vingt-quatre heures. Ce qui reste rare (décider, spécifier, vérifier, assumer) n’a pas bougé d’un pouce. C’est précisément là que se joue la valeur d’un éditeur, solo ou non.&lt;/p&gt;

&lt;p&gt;Si vous pilotez une entreprise et que vous vous demandez où l’IA a un effet réel chez vous plutôt qu’un effet de manche, c’est exactement le genre de contexte qu’on peut regarder ensemble, et trancher ce qui mérite vraiment d’être automatisé.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opinion" />
    <category term="IA" />
    <category term="Productivité" />
    <category term="Freelance" />
    <category term="Opinion" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-06-22-ia-deplace-mon-plafond.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Facture électronique 2026 : la partie que la plateforme ne fera pas pour vous</title>
    <link href="https://sxnlabs.com/opensource/2026/06/08/facture-electronique-2026-reception/" rel="alternate" type="text/html" title="Facture électronique 2026 : la partie que la plateforme ne fera pas pour vous" />
    <published>2026-06-08T15:30:00+02:00</published>
    <updated>2026-06-08T15:30:00+02:00</updated>
    <id>https://sxnlabs.com/opensource/2026/06/08/facture-electronique-2026-reception/</id>
    <content type="html" xml:base="https://sxnlabs.com/opensource/2026/06/08/facture-electronique-2026-reception/">&lt;p&gt;Septembre 2026 est partout. À la radio, dans les mails de la DGFiP, dans les conversations avec les experts-comptables. La date est entrée dans le décor, et c’est plutôt une bonne chose : au moins, cette fois, personne ne découvrira la réforme la veille avec un air surpris et trois fichiers Excel ouverts.&lt;/p&gt;

&lt;p&gt;Le problème n’est donc pas que personne ne parle de septembre 2026. Le problème est ce qu’on met derrière cette date. Beaucoup l’entendent encore comme « le début de la facture électronique », ou comme l’échéance des grandes entreprises qui devront émettre. Pour une TPE ou une PME, l’effet concret est plus immédiat : &lt;strong&gt;à partir du 1ᵉʳ septembre 2026, vos fournisseurs peuvent vous envoyer de vraies factures électroniques, et vous devez être capable de les recevoir.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Recevoir arrive avant émettre, et c’est précisément la partie qu’on ne contrôle pas. Mais la vraie question, derrière la date, n’est pas &lt;em&gt;quel outil acheter&lt;/em&gt; : c’est &lt;em&gt;qui va brancher tout ça sur votre logiciel&lt;/em&gt;. Et là, ni la plateforme ni le comptable ne tiennent le rôle.&lt;/p&gt;

&lt;h2 id=&quot;ce-qui-a-changé-dans-le-dispositif&quot;&gt;Ce qui a changé dans le dispositif&lt;/h2&gt;

&lt;p&gt;Pendant deux ans, le schéma annoncé reposait sur le PPF (le Portail Public de Facturation) comme plateforme par laquelle pourraient transiter les factures. Le dispositif a été recentré : &lt;strong&gt;le PPF n’est plus la plateforme publique de transit promise aux entreprises, mais l’infrastructure d’annuaire et de remontée des données vers l’administration&lt;/strong&gt;. Son rôle consiste à référencer qui reçoit quoi, via quelle plateforme, et à concentrer les informations fiscales utiles à la DGFiP.&lt;/p&gt;

&lt;p&gt;Concrètement, ça veut dire trois choses :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Le transit des factures B2B passe désormais par des &lt;strong&gt;plateformes agréées&lt;/strong&gt; (les ex-PDP), immatriculées par la DGFiP. Plus d’option publique simple où tout le monde irait déposer et récupérer ses factures.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Chorus Pro reste la plateforme de référence pour le B2G&lt;/strong&gt;, la facturation vers le secteur public ne change pas de canal.&lt;/li&gt;
  &lt;li&gt;Les sanctions ont été renforcées : l’amende sur la facture passe de &lt;strong&gt;15 à 50 € par facture&lt;/strong&gt;, l’e-reporting de &lt;strong&gt;250 à 500 € par transmission&lt;/strong&gt; (avec plafonds annuels).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dans la liste officielle au moment de publication, on trouve par exemple des acteurs connus comme Pennylane, Qonto, Sage, Cegid, Tiime, Indy, Yooz ou Esker. Ce n’est pas une liste de recommandations, plutôt un bon rappel que la réception ne passera pas par un formulaire public magique, mais par un choix d’intermédiaire.&lt;/p&gt;

&lt;p&gt;Le calendrier opérationnel reste simple à retenir : &lt;strong&gt;réception obligatoire pour tous au 1ᵉʳ septembre 2026&lt;/strong&gt;, émission pour les grandes entreprises et les ETI à la même date, émission pour les PME et micro-entreprises un an plus tard.&lt;/p&gt;

&lt;h2 id=&quot;pourquoi-la-réception-est-le-piège&quot;&gt;Pourquoi la réception est le piège&lt;/h2&gt;

&lt;p&gt;Quand on parle de septembre 2026, le réflexe naturel est de demander : « est-ce que je dois émettre ? ». Si vous êtes une PME ou une micro-entreprise, la réponse est souvent non, pas encore. Et c’est là que le malentendu commence.&lt;/p&gt;

&lt;p&gt;Quand vous émettez, vous tenez le calendrier. Vous choisissez votre plateforme, vous migrez quand vous êtes prêt, vous testez sur vos propres flux.&lt;/p&gt;

&lt;p&gt;Recevoir, c’est l’inverse : &lt;strong&gt;c’est votre fournisseur qui décide pour vous&lt;/strong&gt;. À partir du 1ᵉʳ septembre 2026, dès qu’un de vos fournisseurs grand compte ou ETI bascule en émission électronique (et eux y sont obligés à cette date), vous devez être capable d’ingérer sa facture. Vous ne choisissez ni le moment, ni le format, ni la plateforme d’en face. Vous subissez.&lt;/p&gt;

&lt;p&gt;Et « être capable de recevoir », ce n’est pas avoir une boîte mail. Au sens de la réforme, une facture électronique prend l’un de ces formats du socle :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;un &lt;strong&gt;XML structuré&lt;/strong&gt; pur (UBL ou CII),&lt;/li&gt;
  &lt;li&gt;ou un &lt;strong&gt;Factur-X&lt;/strong&gt; : un PDF/A-3 lisible par un humain, avec le même XML CII embarqué à l’intérieur pour la machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La différence ne se voit pas à l’œil nu : les deux s’ouvrent dans un lecteur PDF. Si vous voulez savoir dans quelle catégorie tombe ce que produit votre logiciel aujourd’hui, &lt;a href=&quot;/verifier-facture-electronique/&quot;&gt;déposez une de vos factures dans le pré-contrôle Factur-X&lt;/a&gt;, l’analyse tourne dans votre navigateur, le fichier ne part nulle part.&lt;/p&gt;

&lt;p&gt;Recevoir proprement, c’est donc se connecter à une plateforme agréée, récupérer le flux, &lt;strong&gt;parser le XML&lt;/strong&gt;, le valider, puis le rapprocher de vos bons de commande et de vos écritures. C’est un chantier d’intégration, pas un réglage de paramètre. Et c’est précisément le genre de travail qu’on sous-estime tant qu’on ne l’a pas fait.&lt;/p&gt;

&lt;h2 id=&quot;le-format-nest-pas-le-problème&quot;&gt;Le format n’est pas le problème&lt;/h2&gt;

&lt;p&gt;J’ai écrit deux gems open source autour de ce sujet : &lt;a href=&quot;https://github.com/sxnlabs/einvoicing&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;einvoicing&lt;/code&gt;&lt;/a&gt; pour le cœur (génération et lecture de Factur-X, validation, profils CII) et &lt;code class=&quot;highlighter-rouge&quot;&gt;einvoicing-connect&lt;/code&gt; pour le raccordement (Chorus Pro, plateformes agréées, e-reporting). À force de les faire tourner sur de vrais flux, une chose s’est imposée : &lt;strong&gt;le format est la partie facile.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Produire un Factur-X conforme, c’est mécanique. Un PDF/A-3, un XML CII attaché avec le bon nom de fichier et le bon profil, quelques règles de validation. Une bibliothèque fait ça très bien :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;invoice&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Einvoicing&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;profile: :en16931&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;add_line&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;description: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Prestation&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;quantity: &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;unit_price: &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;950_00&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;facturx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;invoice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;to_facturx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;pdf: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rendered_pdf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# PDF/A-3 + XML CII embarqué&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Le vrai travail est ailleurs, dans le &lt;strong&gt;rapprochement métier&lt;/strong&gt;. Une facture reçue, il faut la relier au bon fournisseur, au bon bon de commande, à la bonne TVA, gérer les avoirs, les factures partielles, les écarts de centimes, les identifiants qui ne matchent jamais du premier coup. Le XML vous donne des données propres ; il ne vous dit pas quoi en faire dans &lt;em&gt;votre&lt;/em&gt; comptabilité. C’est là que part 90 % de l’effort, et c’est la partie qu’aucune plateforme ne fera à votre place.&lt;/p&gt;

&lt;h2 id=&quot;le-rôle-que-personne-ne-tient&quot;&gt;Le rôle que personne ne tient&lt;/h2&gt;

&lt;p&gt;Regardez qui intervient vraiment sur le sujet. La plateforme agréée vous livre un flux propre jusqu’à votre porte. L’expert-comptable lit le résultat une fois qu’il est dans la compta. Entre les deux, il reste un espace que ni l’un ni l’autre ne couvre : faire en sorte que &lt;em&gt;votre&lt;/em&gt; logiciel (votre app métier, votre ERP, le tableur autour duquel tout tourne) ingère cette facture, la rapproche et en fasse quelque chose d’utile. Cet espace, c’est de l’intégration. C’est le travail d’un dev.&lt;/p&gt;

&lt;p&gt;Concrètement, ça tient en trois briques :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Le branchement.&lt;/strong&gt; Connecter votre logiciel existant à l’API de la plateforme agréée pour que le flux entrant arrive &lt;em&gt;dans&lt;/em&gt; votre système, pas dans un énième portail à consulter à la main. Personne d’autre ne connaît votre schéma de données.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Le rapprochement.&lt;/strong&gt; Les fameux 90 % : relier chaque facture reçue au bon fournisseur, au bon bon de commande, à la bonne TVA, gérer avoirs et écarts, dans &lt;em&gt;votre&lt;/em&gt; modèle métier. Aucune plateforme ne le fera, parce qu’aucune plateforme ne connaît votre activité.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Le cycle de vie.&lt;/strong&gt; La réforme impose de remonter des statuts de facture (reçue, refusée, encaissée…). Ces transitions doivent vivre dans votre workflow réel, pas dans une interface à part que personne n’ira jamais mettre à jour.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;C’est là que je me place, et c’est ce que je voulais clarifier ici : &lt;strong&gt;je ne vends pas de plateforme. Je fais parler votre logiciel existant la langue de la réforme (ingérer, rapprocher, déclarer), là où la plateforme s’arrête et où votre métier commence.&lt;/strong&gt;&lt;/p&gt;

&lt;h2 id=&quot;la-bonne-question-à-se-poser-maintenant&quot;&gt;La bonne question à se poser maintenant&lt;/h2&gt;

&lt;p&gt;La réforme n’est pas « 2026 pour les gros, 2027 pour les petits », mais &lt;strong&gt;2026 pour tout le monde côté réception&lt;/strong&gt;, puis 2027 pour une partie des entreprises côté émission.&lt;/p&gt;

&lt;p&gt;Donc la question utile va plus loin que « quand dois-je émettre ? » : &lt;strong&gt;« le 1ᵉʳ septembre 2026, suis-je capable de recevoir une facture électronique structurée et de l’intégrer sans la ressaisir à la main ? »&lt;/strong&gt; Si la réponse est non, ce n’est pas un sujet de l’an prochain. C’est un sujet de cet été.&lt;/p&gt;

&lt;p&gt;Choisir une plateforme agréée, brancher la réception, tester le parsing sur quelques vraies factures de vos fournisseurs : ça se fait tranquillement en quelques semaines si on s’y prend maintenant, et dans la douleur si on attend que le premier fournisseur bascule sans prévenir.&lt;/p&gt;

&lt;h2 id=&quot;préparer-la-réception-sans-subir&quot;&gt;Préparer la réception sans subir&lt;/h2&gt;

&lt;p&gt;Un logiciel métier, un ERP bricolé autour d’Excel, une comptabilité qui tient encore parce que quelqu’un ressaisit tout à la main : la facture électronique va vite révéler les endroits où le système fonctionne surtout par patience humaine.&lt;/p&gt;

&lt;p&gt;C’est le genre de sujet que je traite chez &lt;a href=&quot;/contact/?ref=facture-electronique-reception&quot;&gt;SXN Labs&lt;/a&gt; : cadrer le besoin réel, choisir le bon niveau d’automatisation, brancher la réception et faire en sorte que les factures arrivent au bon endroit sans ajouter une usine à gaz de plus. Si votre réception de factures électroniques risque de devenir un chantier pénible, écrivez-moi.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opensource" />
    <category term="E-invoicing" />
    <category term="Factur-X" />
    <category term="Chorus Pro" />
    <category term="Conformité" />
    <category term="Rails" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-06-08-facture-electronique-2026-reception.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Hermès : automatiser mon quotidien sans confier les clés à une IA</title>
    <link href="https://sxnlabs.com/side-project/2026/06/07/hermes-assistant-personnel/" rel="alternate" type="text/html" title="Hermès : automatiser mon quotidien sans confier les clés à une IA" />
    <published>2026-06-07T14:00:00+02:00</published>
    <updated>2026-06-07T14:00:00+02:00</updated>
    <id>https://sxnlabs.com/side-project/2026/06/07/hermes-assistant-personnel/</id>
    <content type="html" xml:base="https://sxnlabs.com/side-project/2026/06/07/hermes-assistant-personnel/">&lt;p&gt;Hermès est mon assistant personnel. Le mien, pas le gros projet open source du même nom qui a fait parler de lui ensuite : j’avais déjà baptisé le dossier &lt;code class=&quot;highlighter-rouge&quot;&gt;~/Hermes&lt;/code&gt; avant qu’il sorte. Le nom était surtout évident pour un système qui transporte des messages, des signaux et des décisions, et le code n’a pas vocation à devenir un produit générique. C’est précisément parce qu’il est très personnel qu’il m’est utile.&lt;/p&gt;

&lt;p&gt;Je l’ai construit parce qu’une partie de mon quotidien professionnel ressemble à celui de beaucoup d’indépendants et de petites équipes : des choses importantes sont éparpillées dans Gmail, GitHub, Sentry, Pennylane, &lt;a href=&quot;https://culturedcode.com/things/&quot;&gt;Things&lt;/a&gt; (mon gestionnaire de tâches), des fichiers Markdown, des dashboards internes et quelques coins de cerveau qui auraient préféré servir à autre chose. Une CI qui tombe, une erreur Sentry qui revient, un devis à relancer, une facture à suivre, un projet presque fini qui risque de rester coincé : aucune de ces tâches n’est très compliquée seule. Leur accumulation, en revanche, finit par créer une charge mentale assez médiocre.&lt;/p&gt;

&lt;p&gt;Le pitch ridicule serait de dire que j’ai créé un “operating system agentique personnel”. On pourrait même ajouter “augmenté par l’IA” pour être sûr de perdre tout le monde sauf deux consultants LinkedIn et un vendeur de formation prompt engineering. La réalité est plus simple : Hermès est un ensemble de petits agents locaux, lancés sur mon Mac, avec des fichiers d’état, des scripts Python, un dashboard privé, quelques intégrations métier, et surtout des règles assez strictes sur ce qu’ils ont le droit de faire.&lt;/p&gt;

&lt;p&gt;Certains agents utilisent un LLM. Beaucoup n’en utilisent pas. C’est un détail important, parce que le bon usage de l’IA n’est pas de remplacer tous les &lt;code class=&quot;highlighter-rouge&quot;&gt;if&lt;/code&gt; par une prière tarifée au million de tokens. Quand une tâche est déterministe, un script bête et fiable est souvent supérieur. Quand il faut résumer des signaux hétérogènes, proposer un arbitrage ou formuler trois options claires, un LLM peut être utile. La difficulté n’est pas d’ajouter de l’IA partout, mais de savoir où elle mérite vraiment d’entrer dans la boucle.&lt;/p&gt;

&lt;h2 id=&quot;un-outil-personnel-pas-un-framework-générique&quot;&gt;Un outil personnel, pas un framework générique&lt;/h2&gt;

&lt;p&gt;Je pense de plus en plus que l’IA devient réellement efficace quand elle est intégrée dans un outil très personnel, au sens strict : un outil fait pour une personne, avec ses réflexes, ses angles morts, ses priorités et sa manière de décider. Hermès prolonge ma façon de travailler plutôt qu’il ne se greffe dessus : mes sources d’information, mes seuils de risque, mes habitudes de décision, et les endroits précis où je veux reprendre la main.&lt;/p&gt;

&lt;p&gt;C’est pour ça que je suis assez méfiant avec les frameworks d’agents génériques vendus comme des raccourcis. Ils peuvent être utiles pour prototyper, mais leur structure embarque déjà une vision du travail : comment la mémoire fonctionne, comment les tâches sont routées, ce qui compte comme une réussite, quand escalader, comment notifier, ce qu’on considère comme une action. Utiliser le framework d’un autre sans le remettre en question, c’est souvent adopter la logique d’un autre. Parfois elle colle à votre problème. Souvent elle colle surtout à la démo de celui qui l’a écrite.&lt;/p&gt;

&lt;p&gt;On a déjà vu ce film avec les SPA React. Pendant des années, des sites vitrines, des back-offices minuscules et des formulaires assez banals ont absorbé la complexité de Facebook en pensant devenir Facebook. Ils ont surtout récupéré le coût mental, les bundles trop lourds, les états clients fragiles et les bugs d’hydratation, sans avoir le problème qui justifiait l’architecture. L’IA prend exactement le même chemin quand on commence par choisir une stack d’agents avant de comprendre le travail réel à automatiser.&lt;/p&gt;

&lt;p&gt;Hermès est donc volontairement construit autour de mes contraintes. Mon inbox est ma todo-list. Things est l’endroit des vraies actions. Le dashboard porte les informations passives. Telegram sert aux arbitrages urgents. Les clients, les devis, les incidents et les projets ont déjà des sources de vérité. Le système n’a pas à inventer un monde parallèle ; il doit relier ce qui existe et me déranger seulement quand ça vaut le coût.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-le-système-doit-résoudre&quot;&gt;Ce que le système doit résoudre&lt;/h2&gt;

&lt;p&gt;Le besoin de départ n’était pas “avoir un agent”. Je n’avais pas besoin d’un chatbot de plus à qui il faut penser à parler, auquel il faut redonner le contexte, puis dont il faut vérifier la réponse. C’est parfois utile pour réfléchir ou produire un brouillon, mais ça ne retire pas vraiment la charge opérationnelle. Ça la déplace dans une fenêtre de chat, avec une interface plus polie et le même problème derrière.&lt;/p&gt;

&lt;p&gt;Ce que je voulais, c’était savoir chaque matin ce qui mérite mon attention, éviter de découvrir trop tard une PR bloquée ou une erreur de production, suivre les devis sans transformer mon cerveau en CRM low-cost, garder le contexte client disponible avant un call, et surtout terminer les sujets proches de la fin au lieu d’ouvrir quinze fronts en parallèle. La plupart de ces besoins sont prosaïques. C’est justement pour ça qu’ils sont importants. Un outil utile commence souvent par retirer de la friction banale, pas par annoncer qu’il va réinventer le travail.&lt;/p&gt;

&lt;p&gt;La frontière que j’ai posée dès le départ est simple : Hermès peut observer, classer, résumer, préparer, signaler et proposer. Il ne doit pas se prendre pour moi. Il ne publie pas un devis, n’envoie pas un mail client, ne prend pas une décision d’architecture, ne modifie pas un projet au-delà de correctifs triviaux, et ne crée pas de bruit pour prouver qu’il travaille. J’ai déjà assez de logiciels qui confondent activité et utilité.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/hermes/dashboard-overview.jpg&quot; alt=&quot;Vue d&#39;ensemble anonymisée du dashboard Hermès&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Vue d’ensemble anonymisée : le dashboard garde les actions, les signaux et l’état des agents au même endroit, sans transformer chaque information en notification.&lt;/em&gt;&lt;/p&gt;

&lt;h2 id=&quot;une-architecture-volontairement-ordinaire&quot;&gt;Une architecture volontairement ordinaire&lt;/h2&gt;

&lt;p&gt;Hermès tourne localement sur mon Mac. Un scheduler géré par &lt;code class=&quot;highlighter-rouge&quot;&gt;launchd&lt;/code&gt; lit un fichier &lt;code class=&quot;highlighter-rouge&quot;&gt;schedule.toml&lt;/code&gt;, regarde quels agents sont dus, applique des préconditions simples, puis lance les jobs. Les runs écrivent leurs logs, leur état, et une page HTML de dashboard. Les états partagés sont des fichiers JSON ou JSONL, les notes plus humaines sont en Markdown, et les tâches déterministes passent par des scripts Python.&lt;/p&gt;

&lt;p&gt;Cette architecture n’impressionnera personne dans une conférence sur l’IA, et c’est très bien. Je préfère un système que je peux comprendre à 23 h, quand quelque chose ne tourne plus, à une plateforme magique où trois abstractions propriétaires masquent un cron, une queue et un prompt. Le jour où ça casse, je veux ouvrir un fichier, lire un log, comprendre quel agent a écrit quoi, et corriger. Le reste est souvent de la décoration vendue au prix du SaaS.&lt;/p&gt;

&lt;p&gt;Le scheduler limite aussi la concurrence. Il ne lance pas vingt agents parce que vingt agents existent. Certains jobs tournent en Python pur, notamment quand la logique est stable et que l’appel LLM ne ferait qu’ajouter du coût et de l’aléatoire. D’autres passent par un modèle quand ils doivent agréger des signaux moins structurés. Ce mélange n’est pas très dogmatique, mais il a le mérite d’être honnête : l’IA est un composant, pas une religion d’architecture.&lt;/p&gt;

&lt;h2 id=&quot;des-agents-avec-un-métier-précis&quot;&gt;Des agents avec un métier précis&lt;/h2&gt;

&lt;p&gt;Chaque agent a un périmètre étroit. C’est probablement le choix le plus important du projet. Un agent “assistant général” finit vite par avaler tout le contexte disponible et agir avec une confiance inversement proportionnelle à sa compréhension réelle. Un agent qui lit une queue précise, produit un état précis et écrit un dashboard précis est beaucoup plus ennuyeux, mais il est aussi beaucoup plus exploitable.&lt;/p&gt;

&lt;p&gt;Le morning briefing prépare la synthèse de début de journée : PRs à revoir, tâches échouées, activité de nuit, signaux Sentry, queue de travail. Inbox triage lit l’inbox Gmail parce que mon inbox est volontairement une todo-list ; il classe les threads, détecte ceux qui vieillissent, peut préparer des brouillons administratifs simples, mais il ne touche pas aux mails client ou prospect. Project focus choisit les trois projets à pousser dans la journée en favorisant ce qui est proche du fini, ce qui a reçu un signal client ou ce qui risque de se bloquer.&lt;/p&gt;

&lt;p&gt;Dev agent est encore plus encadré. Il peut traiter quelques erreurs Sentry évidentes, des merges Dependabot sans risque, ou des corrections CI mécaniques. Il n’a pas le droit de faire du produit, de l’architecture, du refactoring ou des bugs applicatifs ambigus. Le moindre doute sort du scope. C’est frustrant pour une démo, mais très sain pour un outil qui touche à de vrais repos.&lt;/p&gt;

&lt;p&gt;Les agents business suivent le même principe. Pennylane sync récupère les factures, transactions et soldes pour maintenir une vision de trésorerie, mais il reste en lecture seule. Estimates suit les devis et prépare des propositions, mais aucun envoi ou upload public ne part sans validation explicite. Client context keeper reconstruit des dossiers clients vivants à partir des devis, factures, conversations récentes, PRs et incidents, en préservant les notes manuelles. Rien de tout ça ne remplace le jugement humain. J’arrive devant la décision avec le bon contexte déjà rassemblé.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/hermes/project-focus.jpg&quot; alt=&quot;Priorisation anonymisée des projets dans Hermès&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Project Focus ne cherche pas le projet le plus séduisant. Il pousse ce qui doit avancer : projets presque finis, signaux clients récents, blocages explicites.&lt;/em&gt;&lt;/p&gt;

&lt;h2 id=&quot;lattention-comme-contrainte-produit&quot;&gt;L’attention comme contrainte produit&lt;/h2&gt;

&lt;p&gt;Le plus gros piège de l’automatisation, c’est le système qui marche assez pour produire du bruit. Une alerte inutile devient une interruption, une interruption répétée devient un réflexe d’ignorance, et un système ignoré finit par être pire qu’un système absent : il donne l’impression qu’une surveillance existe alors que plus personne ne l’écoute.&lt;/p&gt;

&lt;p&gt;Hermès sépare donc les canaux. Le dashboard contient les informations passives, Things contient les actions que je dois réellement faire, Telegram sert aux urgences ou aux arbitrages courts, et les fichiers d’état gardent l’historique pour les prochains runs. Une facture attendue dans trois jours n’est pas forcément une action. Un client à relancer, oui. Une PR déjà mergée ne doit pas recréer un todo parce qu’un agent a relu un vieux JSON avec l’enthousiasme administratif d’un formulaire Cerfa sous amphétamines.&lt;/p&gt;

&lt;p&gt;Il y a de la déduplication, des fenêtres anti-spam, des règles “un sujet = un todo”, et des cas où Hermès choisit explicitement de ne rien me pousser. C’est moins spectaculaire qu’une avalanche de notifications, mais c’est ce qui rend le système utilisable. L’attention est une ressource limitée ; un assistant qui la gaspille travaille contre moi, même si ses intentions sont statistiquement bienveillantes.&lt;/p&gt;

&lt;h2 id=&quot;les-refus-sont-une-fonctionnalité&quot;&gt;Les refus sont une fonctionnalité&lt;/h2&gt;

&lt;p&gt;La partie la plus importante d’Hermès n’est pas la liste de ce qu’il peut faire, mais celle de ce qu’il refuse. Pas d’envoi automatique de mail client. Pas de publication de devis sans validation explicite. Pas de modification de code au-delà d’un périmètre trivial. Pas de décision d’architecture. Pas de todo Things pour la cuisine interne. Pas de “je pense que…” quand le système peut poser une question claire avec trois options actionnables.&lt;/p&gt;

&lt;p&gt;C’est là que l’approche produit compte vraiment. Quand on découvre les agents, le réflexe naturel est d’ajouter des capacités : répondre aux clients, publier les devis, merger les PRs, décider du prochain chantier, réorganiser le planning. Certaines de ces capacités peuvent être pertinentes un jour. Mais la bonne question n’est pas “est-ce possible techniquement ?”. La bonne question est “quelle erreur devient possible si je l’autorise ?”.&lt;/p&gt;

&lt;p&gt;Un agent qui peut tout faire n’est pas nécessairement puissant. Il offre souvent une surface d’accident avec une bonne UX. Hermès est utile parce qu’il sait s’arrêter aux endroits où mon jugement reste nécessaire : arbitrer un besoin client, accepter un risque commercial, valider une proposition, décider qu’une demande contredit la raison d’être d’un logiciel. Ces décisions ne sont pas des détails gênants à automatiser ; ce sont précisément les endroits où se trouve la valeur.&lt;/p&gt;

&lt;h2 id=&quot;une-mémoire-inspectable&quot;&gt;Une mémoire inspectable&lt;/h2&gt;

&lt;p&gt;La mémoire d’Hermès tient dans des fichiers. JSON, JSONL, Markdown. C’est basique, versionnable, inspectable et réparable. Chaque agent lit ce dont il a besoin et écrit dans des zones précises : queue de dev, mémoire des PRs, tâches échouées, signaux projet, réponses aux questions, dossiers clients.&lt;/p&gt;

&lt;p&gt;Je n’ai rien contre la recherche sémantique ; Hermès en utilise quand elle apporte quelque chose. Mais la mémoire opérationnelle d’un système de travail doit rester lisible. Un agent qui justifie une décision par “j’ai trouvé ça dans ma mémoire” sans qu’on puisse inspecter la source n’est pas intelligent. Il est pénible à auditer, ce qui est une manière très moderne d’être dangereux.&lt;/p&gt;

&lt;p&gt;Le dashboard joue le même rôle. Il ne sert pas à faire joli, même si j’ai fini par lui donner une forme correcte parce que je suis faible devant une interface propre. Il permet surtout de vérifier qu’un agent a tourné, ce qu’il a vu, ce qu’il a fait, et parfois ce qu’il a refusé de faire. Les dates sont absolues, pas relatives, parce qu’un “il y a deux heures” devient vite faux dans une page mise en cache ou une capture. Les timestamps viennent du système, pas de l’imagination du modèle. Un agent qui écrit une date future n’est pas visionnaire, il est juste cassé.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-ça-montre-vraiment&quot;&gt;Ce que ça montre vraiment&lt;/h2&gt;

&lt;p&gt;Hermès est un side project, mais pas un caprice technique. J’y traite mon activité comme un produit interne. Le besoin n’était pas d’utiliser de l’IA ; le besoin était de réduire les oublis, prioriser le travail réellement utile, garder une vision financière exploitable, éviter les tâches administratives répétitives, rendre le contexte client disponible au bon moment et préserver les décisions humaines là où elles ont de la valeur.&lt;/p&gt;

&lt;p&gt;Cette logique ressemble beaucoup à celle que j’applique chez mes clients. Avant de développer, il faut comprendre le travail réel : qui fait quoi, avec quelles informations, dans quel ordre, avec quels risques, et quelles décisions ne doivent surtout pas être automatisées. Ensuite seulement on choisit l’outil. Parfois il faut un logiciel sur mesure complet. Parfois il faut automatiser trois étapes pénibles. Parfois il faut surtout arrêter de demander à Excel de jouer l’ERP, le CRM, le planning, le reporting et la conscience collective de l’entreprise. Excel est très fort, mais il a lui aussi droit à une retraite digne.&lt;/p&gt;

&lt;p&gt;Je ne vais pas publier Hermès tel quel. Il contient ma vie professionnelle, mes clients, mes finances, mes emails et assez de chemins internes pour faire tousser n’importe quel RSSI normalement constitué. Les principes, eux, sont réutilisables : partir d’une friction récurrente, distinguer information, action et décision, donner un périmètre étroit à chaque automatisation, garder l’état inspectable, mettre l’humain dans la boucle aux bons endroits, et utiliser un LLM pour l’ambiguïté plutôt que pour remplacer un script fiable.&lt;/p&gt;

&lt;h2 id=&quot;si-ce-genre-de-situation-vous-parle&quot;&gt;Si ce genre de situation vous parle&lt;/h2&gt;

&lt;p&gt;Hermès est personnel, mais le problème est très courant : informations dispersées, processus manuels, relances oubliées, décisions prises sans vue d’ensemble, outils SaaS trop génériques, Excel qui tient encore debout par patriotisme local. Dans ces situations, la bonne question n’est pas “comment mettre de l’IA dans l’entreprise ?”, mais plutôt : quelles tâches reviennent tout le temps, quelles décisions demandent vraiment un humain, quelles informations arrivent trop tard, et quels outils existants peuvent être reliés au lieu d’être remplacés.&lt;/p&gt;

&lt;p&gt;C’est le genre de travail que je fais chez &lt;a href=&quot;/contact/?ref=hermes-agent&quot;&gt;SXN Labs&lt;/a&gt; : comprendre le métier, trouver les vraies frictions, construire le logiciel ou les automatisations qui couvrent le besoin, puis laisser derrière un système simple à utiliser, observable et maintenable. Pas une démonstration magique. Un outil qui sert, avec suffisamment de limites pour rester fréquentable.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="ai" />
    <category term="IA" />
    <category term="Automatisation" />
    <category term="Produit" />
    <category term="Agents" />
    <category term="Side project" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/posts/hermes/dashboard-overview.jpg" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Brancher INSi avec TLS strict et continuité métier</title>
    <link href="https://sxnlabs.com/ruby/2026/06/04/racine-igc-sante-tls-insi/" rel="alternate" type="text/html" title="Brancher INSi avec TLS strict et continuité métier" />
    <published>2026-06-04T18:00:00+02:00</published>
    <updated>2026-06-04T18:00:00+02:00</updated>
    <id>https://sxnlabs.com/ruby/2026/06/04/racine-igc-sante-tls-insi/</id>
    <content type="html" xml:base="https://sxnlabs.com/ruby/2026/06/04/racine-igc-sante-tls-insi/">&lt;p&gt;Un mardi matin, sur un logiciel métier de santé en production dont j’assure le développement et la maintenance, tous les appels au téléservice INSi de la CNAM se sont mis à échouer côté serveur. Plus aucune vérification d’identité patient ne passait.&lt;/p&gt;

&lt;p&gt;Côté utilisateur, le logiciel faisait ce qu’il devait faire : il n’affichait pas une stacktrace, il indiquait que la vérification INS n’avait pas abouti. Le patient était là, l’acte pouvait continuer, mais l’identité restait non vérifiée.&lt;/p&gt;

&lt;p&gt;L’erreur sous-jacente, remontée par Sentry, tenait en une ligne :&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;OpenSSL::SSL::SSLError: SSL_connect returned=1 errno=0
  certificate verify failed (self-signed certificate in certificate chain)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Le diagnostic a pris vingt minutes. La correction propre, deux heures. Mais le sujet n’était pas de reprendre l’UX ou le comportement métier. L’intégration INSi tournait en production avec une vérification TLS stricte et un comportement correct en cas d’échec ; l’incident a surtout rendu visible un sujet d’exploitation qu’il fallait expliciter : la confiance IGC-Santé.&lt;/p&gt;

&lt;p&gt;Il fallait remettre le téléservice en marche, sans baisser la sécurité, et conserver cette propriété importante : une panne externe ne devait pas bloquer le travail clinique.&lt;/p&gt;

&lt;p&gt;Il existe une mauvaise réponse à ce problème, recopiée partout sur les forums, qu’il faut absolument éviter quand on transporte de l’identité patient. Elle “corrige” TLS à peu près comme couper le voyant moteur corrige une panne.&lt;/p&gt;

&lt;h2 id=&quot;le-besoin-réel&quot;&gt;Le besoin réel&lt;/h2&gt;

&lt;p&gt;Le besoin métier n’était pas “réussir un handshake TLS”. C’était beaucoup plus simple :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Vérifier l’identité INS d’un patient quand le téléservice répond.&lt;/li&gt;
  &lt;li&gt;Permettre au soignant de continuer son acte quand le téléservice ne répond pas.&lt;/li&gt;
  &lt;li&gt;Marquer clairement la donnée comme provisoire tant qu’elle n’a pas été vérifiée.&lt;/li&gt;
  &lt;li&gt;Savoir avant la panne suivante qu’un certificat va expirer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La partie TLS n’était qu’un morceau du problème. Important, mais pas suffisant. Une intégration critique a aussi besoin d’une réponse explicite pour les jours où la dépendance externe ne répond pas.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-fait-insi&quot;&gt;Ce que fait INSi&lt;/h2&gt;

&lt;p&gt;INSi est le téléservice de la CNAM qui permet à un logiciel métier d’aller chercher ou vérifier l’&lt;strong&gt;Identifiant National de Santé&lt;/strong&gt; d’un patient à partir de ses traits civils : nom, prénom, date et lieu de naissance. C’est une brique centrale pour produire, échanger ou archiver des données de santé avec la bonne identité patient.&lt;/p&gt;

&lt;p&gt;L’endpoint vit sur &lt;code class=&quot;highlighter-rouge&quot;&gt;services-ps-tlsm.ameli.fr&lt;/code&gt;. C’est du SOAP, authentifié par certificat client délivré via le Portail de Confiance ANS. Côté serveur, la chaîne TLS remonte à la &lt;strong&gt;racine IGC-Santé&lt;/strong&gt;, l’autorité de certification de l’État pour l’e-santé française.&lt;/p&gt;

&lt;p&gt;Cette racine n’est dans &lt;strong&gt;aucun magasin de confiance système par défaut&lt;/strong&gt;. Ni Debian, ni Ubuntu, ni macOS, ni le bundle Mozilla embarqué dans la plupart des libs HTTP. C’est une PKI gouvernementale spécialisée, séparée du Web PKI grand public. Si vous ne l’ajoutez pas explicitement, OpenSSL ne la devinera pas par communion administrative.&lt;/p&gt;

&lt;h2 id=&quot;la-fausse-bonne-idée&quot;&gt;La fausse bonne idée&lt;/h2&gt;

&lt;p&gt;Si vous tapez “OpenSSL self-signed certificate in certificate chain Ruby” dans un moteur de recherche, vous tomberez sur dix réponses StackOverflow qui disent la même chose :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# NE FAITES PAS ÇA SUR UN FLUX D&#39;IDENTITÉ PATIENT&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;http&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;verify_mode&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;OpenSSL&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;SSL&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;VERIFY_NONE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Sur un script perso qui scrape un site avec un certificat auto-signé, chacun négocie avec sa conscience. Sur un flux qui transporte le numéro de sécu, le nom et la date de naissance d’un patient vers un service authentifié par certificat client ? &lt;strong&gt;Surtout pas.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Désactiver &lt;code class=&quot;highlighter-rouge&quot;&gt;verify_mode&lt;/code&gt;, c’est :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Accepter n’importe quel certificat en face, y compris un attaquant en MitM sur le réseau.&lt;/li&gt;
  &lt;li&gt;Trouer la seule garantie cryptographique que la machine en face est bien celle de la CNAM.&lt;/li&gt;
  &lt;li&gt;Inscrire dans le code, durablement, une régression de sécurité que personne ne reverra jamais.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Et pour rien, puisque la vraie réponse demande peu de temps, elle est documentable, et elle laisse la sécurité en place. Détail appréciable quand on parle d’identité patient.&lt;/p&gt;

&lt;h2 id=&quot;la-correction-propre&quot;&gt;La correction propre&lt;/h2&gt;

&lt;p&gt;L’ANS publie publiquement la chaîne IGC-Santé sur son site (autorités racine + intermédiaires, format PEM). Le bon réflexe :&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Télécharger la chaîne officielle&lt;/strong&gt; depuis le portail ANS.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Vérifier les empreintes&lt;/strong&gt; : SHA-256 du certificat récupéré comparé à celui présenté par l’endpoint live, et à celui publié par l’ANS. Trois sources, un seul hash. Si l’un des trois diffère, on s’arrête.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;L’embarquer dans le projet&lt;/strong&gt;, sous version, distinct des certificats clients (qui eux restent en credentials chiffrés).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Pointer OpenSSL dessus&lt;/strong&gt; au moment d’instancier le client SOAP.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Côté Rails, ça donne, en simplifiant, ceci dans le service &lt;code class=&quot;highlighter-rouge&quot;&gt;Ameli::Insi&lt;/code&gt; :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Ameli::Insi&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;http_client&lt;/span&gt;
    &lt;span class=&quot;no&quot;&gt;Savon&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
      &lt;span class=&quot;ss&quot;&gt;wsdl: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;wsdl_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;ss&quot;&gt;ssl_cert_file: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client_cert_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;ss&quot;&gt;ssl_cert_key_file: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client_key_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;ss&quot;&gt;ssl_ca_cert_file: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;igc_sante_bundle_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;# &amp;lt;- la chaîne publique embarquée&lt;/span&gt;
      &lt;span class=&quot;ss&quot;&gt;ssl_verify_mode: :peer&lt;/span&gt;                   &lt;span class=&quot;c1&quot;&gt;# &amp;lt;- on garde la vérif stricte&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;

  &lt;span class=&quot;kp&quot;&gt;private&lt;/span&gt;

  &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;igc_sante_bundle_path&lt;/span&gt;
    &lt;span class=&quot;no&quot;&gt;Rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;root&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;lib&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;certs&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;igc_sante.pem&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;to_s&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Pour valider de bout en bout avant même de redéployer, &lt;code class=&quot;highlighter-rouge&quot;&gt;openssl&lt;/code&gt; fait le boulot :&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;openssl s_client &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-connect&lt;/span&gt; services-ps-tlsm.ameli.fr:443 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-CAfile&lt;/span&gt; lib/certs/igc_sante.pem &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-servername&lt;/span&gt; services-ps-tlsm.ameli.fr &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-showcerts&lt;/span&gt; &amp;lt; /dev/null 2&amp;gt;&amp;amp;1 | &lt;span class=&quot;nb&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Verify return code&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Sortie attendue : &lt;code class=&quot;highlighter-rouge&quot;&gt;Verify return code: 0 (ok)&lt;/code&gt;. À partir de là, le client Ruby remarche, en &lt;code class=&quot;highlighter-rouge&quot;&gt;VERIFY_PEER&lt;/code&gt;, sans aucune dégradation de sécurité.&lt;/p&gt;

&lt;h2 id=&quot;le-comportement-utile&quot;&gt;Le comportement utile&lt;/h2&gt;

&lt;p&gt;Une fois la cause racine corrigée, il restait un point intéressant à expliciter : pourquoi l’incident n’avait pas bloqué le soignant ?&lt;/p&gt;

&lt;p&gt;Parce que le logiciel ne traitait pas l’échec INSi comme une exception à jeter à l’écran, mais comme un état métier. Quand INSi répond, l’identité patient est vérifiée. Quand le téléservice ne répond pas, l’erreur technique reste dans Sentry et l’interface indique au soignant que la vérification INS n’a pas pu être effectuée.&lt;/p&gt;

&lt;p&gt;La frontière applicative ressemble à ça, en simplifiant :&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;fetch_by_traits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;patient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;http_client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;call&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:fetch_by_traits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;message: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload_for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;patient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
  &lt;span class=&quot;no&quot;&gt;Ameli&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Insi&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Identity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;from_soap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;rescue&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;HTTPI&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Savon&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Error&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;
  &lt;span class=&quot;no&quot;&gt;Sentry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;capture_exception&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;extra: &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;patient_id: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;patient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Ameli&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Insi&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;TeleserviceUnavailable&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Le contrôleur appelant rescue &lt;code class=&quot;highlighter-rouge&quot;&gt;TeleserviceUnavailable&lt;/code&gt; et conserve une &lt;strong&gt;identité provisoire&lt;/strong&gt; (les traits civils saisis par le soignant, taggés “non-vérifiés INS”), avec un bandeau qui explique qu’INSi est indisponible et qu’il faudra re-vérifier plus tard. Le soignant continue son acte, le patient n’est pas bloqué, et la donnée sera consolidée au prochain appel réussi.&lt;/p&gt;

&lt;p&gt;C’était déjà le comportement utile. Pas spectaculaire, pas très vendeur en démo, mais c’est lui qui compte en production : une dépendance externe qui tombe ne doit pas arrêter la chaîne de soins. Elle doit être traduite en mode dégradé explicite.&lt;/p&gt;

&lt;h2 id=&quot;le-travail-invisible&quot;&gt;Le travail invisible&lt;/h2&gt;

&lt;p&gt;Cet incident a aussi rappelé un point d’exploitation facile à sous-estimer. Les certificats clients INSi, MSSanté ou Pro Santé Connect sont renouvelés à la main sur le Portail de Confiance ANS, puis stockés dans des credentials chiffrés. L’application peut fonctionner parfaitement pendant des mois, jusqu’au jour où une échéance arrive en silence.&lt;/p&gt;

&lt;p&gt;Le jour où l’un d’eux expire, on retrouve exactement la même panne, avec un sentiment de déjà-vu et une envie raisonnable d’insulter un calendrier.&lt;/p&gt;

&lt;p&gt;Donc dans la foulée, j’ai ajouté un job quotidien qui parcourt l’inventaire des certificats embarqués : chaînes publiques et certificats clients. Il remonte à Sentry tout certificat à moins de 30 jours d’expiration, puis passe en erreur à 7 jours ou si le certificat est déjà expiré.&lt;/p&gt;

&lt;p&gt;J’ai aussi ajouté un runbook de renouvellement versionné dans le repo (&lt;code class=&quot;highlighter-rouge&quot;&gt;lib/certs/README.md&lt;/code&gt;), avec la procédure PFC pas à pas. Personne ne le verra tant que ça marche. Le jour où ça menace de casser, ce sera un avertissement exploitable, pas une panne surprise.&lt;/p&gt;

&lt;h2 id=&quot;mini-cartographie-de-la-pki-e-santé-française&quot;&gt;Mini-cartographie de la PKI e-santé française&lt;/h2&gt;

&lt;p&gt;Pour qui découvre l’écosystème, les briques se ressemblent et c’est facile de s’y perdre :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;INS&lt;/strong&gt; : l’identifiant national de santé du patient (NIR ou NIA + traits). C’est la donnée.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;INSi&lt;/strong&gt; : le téléservice CNAM qui permet de récupérer / vérifier un INS. C’est l’API.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;MSSanté&lt;/strong&gt; : la messagerie sécurisée entre professionnels et avec le patient. PKI séparée, certificats émis par les opérateurs MSSanté.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Pro Santé Connect&lt;/strong&gt; : le SSO d’État pour les professionnels de santé (carte CPS dématérialisée, OIDC).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;IGC-Santé&lt;/strong&gt; : la racine PKI commune qui signe l’essentiel de tout ça côté serveur ANS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Chaque brique est documentée, chaque chaîne est publique et vérifiable. Le piège, c’est de traiter ça comme un détail technique gênant alors que c’est une partie du produit : identité, sécurité, continuité de service, procédures d’exploitation.&lt;/p&gt;

&lt;h2 id=&quot;ce-que-je-retiens&quot;&gt;Ce que je retiens&lt;/h2&gt;

&lt;p&gt;Quand une intégration TLS casse en production, la bonne réponse n’est presque jamais “désactiver la vérification”. C’est : &lt;strong&gt;trouver le maillon de confiance manquant&lt;/strong&gt;, le rendre explicite, le versionner, et l’auditer dans le temps. Le réflexe &lt;code class=&quot;highlighter-rouge&quot;&gt;VERIFY_NONE&lt;/code&gt; économise dix minutes sur le moment et laisse une dette de sécurité qui vieillira tranquillement dans le code.&lt;/p&gt;

&lt;p&gt;L’autre point, plus produit, c’est qu’une intégration critique doit avoir un mode dégradé. Un téléservice externe en panne, c’est normal sur dix ans d’exploitation. Ici, cette décision existait déjà côté utilisateur : la vérification INS échouait, le soignant était informé, et le soin pouvait continuer.&lt;/p&gt;

&lt;p&gt;Le vrai travail a donc été de couvrir la boucle complète : comprendre le besoin clinique, rendre la chaîne de confiance explicite, préserver la sécurité, s’appuyer sur le fallback métier existant, puis surveiller les certificats pour éviter de rejouer la même scène trois mois plus tard avec un autre certificat.&lt;/p&gt;

&lt;h2 id=&quot;si-ce-genre-de-situation-vous-parle&quot;&gt;Si ce genre de situation vous parle&lt;/h2&gt;

&lt;p&gt;Les intégrations e-santé françaises (INSi, MSSanté, Pro Santé Connect, DMP), c’est un mélange de PKI, de certificats, de WSDL et de procédures ANS qui demandent plus de patience que de génie. Ça se fait. Ça marche. Mais il faut cadrer le besoin métier autant que le branchement technique.&lt;/p&gt;

&lt;p&gt;C’est le genre de sujet que je traite chez &lt;a href=&quot;/contact/?ref=insi-tls&quot;&gt;SXN Labs&lt;/a&gt; : comprendre ce qui doit vraiment continuer à fonctionner, simplifier le périmètre, brancher proprement, puis laisser derrière un système exploitable. Si vous avez un téléservice santé coincé, écrivez-moi.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="ruby" />
    <category term="e-santé" />
    <category term="TLS" />
    <category term="PKI" />
    <category term="Rails" />
    <category term="INSi" />
    <category term="Retour d&#39;expérience" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-06-04-racine-igc-sante-tls-insi.fr.png" />
  </entry>
  <entry xml:lang="fr">
    <title type="html">Piloter mon spa Intex sans son app</title>
    <link href="https://sxnlabs.com/side-project/2026/05/31/piloter-mon-spa-intex-sans-leur-app/" rel="alternate" type="text/html" title="Piloter mon spa Intex sans son app" />
    <published>2026-05-31T18:00:00+02:00</published>
    <updated>2026-05-31T18:00:00+02:00</updated>
    <id>https://sxnlabs.com/side-project/2026/05/31/piloter-mon-spa-intex-sans-leur-app/</id>
    <content type="html" xml:base="https://sxnlabs.com/side-project/2026/05/31/piloter-mon-spa-intex-sans-leur-app/">&lt;p&gt;J’ai un spa gonflable Intex PureSpa Baltik dans le jardin. Il fonctionne très bien. L’application iOS qui va avec, en revanche, est un drame : il faut un compte cloud Tuya, l’auth lâche tous les trois jours, la moitié des commandes mettent 10 secondes à arriver, et l’interface ressemble à un POC oublié en 2018. Pour un objet qui passe sa vie sur mon Wi-Fi à 3 mètres de mon Mac, l’aller-retour par un serveur en Chine me paraissait inutilement absurde.&lt;/p&gt;

&lt;p&gt;Donc j’ai jeté l’app et j’ai écrit la mienne. Un week-end de reverse engineering, puis quelques soirs pour empiler les features que l’app officielle n’aura jamais. Voici ce que ça donne.&lt;/p&gt;

&lt;h2 id=&quot;le-reverse-engineering-en-30-minutes&quot;&gt;Le reverse engineering en 30 minutes&lt;/h2&gt;

&lt;p&gt;J’ai eu une chance énorme : &lt;a href=&quot;https://github.com/mathieu-mp/aio-intex-spa&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;mathieu-mp/aio-intex-spa&lt;/code&gt;&lt;/a&gt; avait déjà fait le boulot ingrat. Le module Wi-Fi du spa écoute en TCP sur le port 8990, le protocole est binaire avec un checksum simple (modulo 0xFF, pas 0x100, c’est le piège classique), et les commandes fonctionnelles (power, chauffe, filtration, bulles) sont des &lt;strong&gt;toggles&lt;/strong&gt; : on lit l’état courant et on n’envoie que si l’état désiré diffère. C’est idempotent par construction.&lt;/p&gt;

&lt;p&gt;J’ai validé byte-for-byte contre mon vrai spa avec un &lt;code class=&quot;highlighter-rouge&quot;&gt;probe.py&lt;/code&gt; autonome (stdlib only, zéro dépendance), puis empaqueté ça dans une couche &lt;code class=&quot;highlighter-rouge&quot;&gt;protocol.py&lt;/code&gt; pure qui se teste hors ligne.&lt;/p&gt;

&lt;p&gt;Toute l’architecture découle de ces invariants :&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Une seule connexion TCP&lt;/strong&gt;. Le firmware n’accepte qu’un client à la fois sur 8990. Tout passe par un unique &lt;code class=&quot;highlighter-rouge&quot;&gt;IntexSpaClient&lt;/code&gt; avec un lock asyncio, possédé par un unique &lt;code class=&quot;highlighter-rouge&quot;&gt;Supervisor&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Le polling sert de keepalive&lt;/strong&gt;. Le firmware ferme la socket si rien ne parle pendant trop longtemps. Donc on poll toutes les 10 s et ça nourrit aussi l’UI en SSE.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Stale-but-useful&lt;/strong&gt;. Si le spa devient injoignable, on garde la dernière lecture connue et on affiche un bandeau « hors ligne ». Le prochain poll récupère tout.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;ce-que-lapp-officielle-ne-fera-jamais&quot;&gt;Ce que l’app officielle ne fera jamais&lt;/h2&gt;

&lt;h3 id=&quot;une-vraie-web-ui&quot;&gt;Une vraie web UI&lt;/h3&gt;

&lt;p&gt;FastAPI + HTMX + Chart.js (vendorisé, pas de CDN, l’app vit sur le LAN, elle doit marcher si Internet tombe). Une page mobile-first, un graphique 7 jours de la température, des contrôles instantanés. Le tout servi en un seul processus uvicorn sur mon Mac qui tourne H24.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/spa/desktop-overview.png&quot; alt=&quot;Dashboard desktop, jauge de température, toggles, graphique 7 jours, caméra, météo, scheduler&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;un-scheduler-météo-aware&quot;&gt;Un scheduler météo-aware&lt;/h3&gt;

&lt;p&gt;L’app Intex propose un timer rudimentaire qui n’est même pas exposé via le protocole LAN, donc j’ai écrit le mien. Trois primitives : consigne par tranche horaire, fenêtres de filtration, et surtout &lt;strong&gt;« prêt à 18 h »&lt;/strong&gt; qui calcule le moment de démarrer la chauffe.&lt;/p&gt;

&lt;p&gt;La nouveauté intéressante est que le taux de chauffe n’est pas constant. Un spa perd de la chaleur proportionnellement à &lt;code class=&quot;highlighter-rouge&quot;&gt;(eau − air extérieur)&lt;/code&gt;. Donc je tire la météo locale depuis Open-Meteo (gratuit, sans clé API), j’apprends le coefficient de pertes thermiques sur l’historique des phases de chauffe et de refroidissement, et le scheduler décale automatiquement le démarrage plus tôt quand il fait froid. Si la nuit s’annonce à 4 °C, on commence à chauffer 90 min avant ; si c’est 18 °C, 30 min suffisent.&lt;/p&gt;

&lt;h3 id=&quot;une-caméra-avec-détection-de-housse-expérimentale&quot;&gt;Une caméra avec détection de housse (expérimentale)&lt;/h3&gt;

&lt;p&gt;Le spa est à moitié visible dans le champ d’une caméra IP du jardin. J’ai branché ffmpeg pour grabber une frame toutes les 10 s, l’écrire atomiquement (&lt;code class=&quot;highlighter-rouge&quot;&gt;tmp + replace&lt;/code&gt;), reconstruire un timelapse mp4 quotidien à la volée, et (pour le fun) détecter si la housse est en place via une ROI calibrable et une heuristique luma + écart-type. C’est partiel et capricieux la nuit, donc ce n’est pas branché sur le scheduler en v1. Mais la plomberie est là pour le jour où.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/spa/mobile-camera-settings.png&quot; alt=&quot;Carte caméra avec panneau de réglages, mobile&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;un-service-launchd-qui-survit-aux-silent-failures&quot;&gt;Un service launchd qui survit aux silent failures&lt;/h3&gt;

&lt;p&gt;C’est la partie qui m’a pris le plus de temps et que personne ne raconte. Sur cette machine, &lt;strong&gt;CPython 3.14 tuait silencieusement le service au bout de ~30 s&lt;/strong&gt; sous launchd. Pas de traceback, pas de log, juste un process qui meurt. Le coupable est un segfault dans &lt;code class=&quot;highlighter-rouge&quot;&gt;uvloop&lt;/code&gt; / &lt;code class=&quot;highlighter-rouge&quot;&gt;httptools&lt;/code&gt; / &lt;code class=&quot;highlighter-rouge&quot;&gt;pydantic-core&lt;/code&gt; en cas de boucle longue. Retour à 3.12, problème évaporé. Et puis tous les paramètres plist qu’on apprend à la dure : &lt;code class=&quot;highlighter-rouge&quot;&gt;ThrottleInterval=15&lt;/code&gt; pour éviter les boucles de respawn qui saturent launchd, &lt;code class=&quot;highlighter-rouge&quot;&gt;ProcessType=Adaptive&lt;/code&gt; (pas &lt;code class=&quot;highlighter-rouge&quot;&gt;Background&lt;/code&gt;, sinon le jetsam nous tue en premier sous pression mémoire), &lt;code class=&quot;highlighter-rouge&quot;&gt;ExitTimeOut=20&lt;/code&gt; pour laisser le temps de fermer la TCP proprement, et surtout &lt;strong&gt;pas de &lt;code class=&quot;highlighter-rouge&quot;&gt;--workers 1&lt;/code&gt;&lt;/strong&gt; sur uvicorn (ce flag bascule en mode multiprocess et wedge sous launchd ; le défaut single-process fait exactement ce qu’on veut).&lt;/p&gt;

&lt;h2 id=&quot;stack&quot;&gt;Stack&lt;/h2&gt;

&lt;p&gt;Python 3.12 + FastAPI + HTMX + Chart.js + ffmpeg + Open-Meteo + launchd + ngrok pour l’accès distant. Pas de base de données. Tout l’état tient dans une poignée de fichiers JSON et JSONL sous &lt;code class=&quot;highlighter-rouge&quot;&gt;state/&lt;/code&gt;. 135 tests offline qui tournent en 3 secondes sans toucher au vrai spa (un &lt;code class=&quot;highlighter-rouge&quot;&gt;fake_spa.py&lt;/code&gt; rejoue le protocole).&lt;/p&gt;

&lt;h2 id=&quot;le-pattern-réutilisable&quot;&gt;Le pattern réutilisable&lt;/h2&gt;

&lt;p&gt;Cette boîte noire ressemble à toutes les autres : un device IoT bas de gamme qui exige un compte cloud pour faire le boulot le plus trivial. Le pattern marche aussi pour les ampoules Tuya, les volets Somfy, et probablement la moitié des trucs dans votre maison :&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Trouver le port TCP ouvert sur le LAN (un &lt;code class=&quot;highlighter-rouge&quot;&gt;nmap&lt;/code&gt; suffit souvent).&lt;/li&gt;
  &lt;li&gt;Capturer quelques échanges avec Wireshark depuis l’app officielle.&lt;/li&gt;
  &lt;li&gt;Identifier le checksum et le framing (la doc du protocole est rarement publique mais rarement compliquée).&lt;/li&gt;
  &lt;li&gt;Réécrire un client minimal, valider byte-for-byte, ajouter ce que le vendeur n’a jamais voulu construire.&lt;/li&gt;
  &lt;li&gt;Bloquer la sortie WAN du device au routeur, fin du phone-home.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Le code est public, maintenant découpé en deux dépôts : &lt;a href=&quot;https://github.com/sxnlabs/onsen-server&quot;&gt;github.com/sxnlabs/onsen-server&lt;/a&gt; pour le service FastAPI et le protocole du spa, et &lt;a href=&quot;https://github.com/sxnlabs/onsen-app&quot;&gt;github.com/sxnlabs/onsen-app&lt;/a&gt; pour l’application. Licences permissives. Si vous avez un PureSpa Baltik, vous pouvez littéralement forker le serveur et l’installer. Et si vous avez un autre device avec le même pattern, lisez &lt;code class=&quot;highlighter-rouge&quot;&gt;intex_spa/protocol.py&lt;/code&gt; côté serveur, c’est un bon template pour démarrer le vôtre.&lt;/p&gt;

&lt;h2 id=&quot;si-ce-genre-de-situation-vous-parle&quot;&gt;Si ce genre de situation vous parle&lt;/h2&gt;

&lt;p&gt;Cette histoire de boîte noire, je la croise régulièrement côté pro : une machine industrielle avec une API non documentée, un capteur derrière une app cloud qui ralentit tout le monde, une intégration domotique B2B qu’un fournisseur refuse de faire. C’est exactement ce que je débloque chez &lt;a href=&quot;/contact/?ref=spa-intex&quot;&gt;SXN Labs&lt;/a&gt;. Si vous avez un device coincé qui devrait juste parler à votre SI, écrivez-moi.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="side-project" />
    <category term="IoT" />
    <category term="Reverse engineering" />
    <category term="Python" />
    <category term="FastAPI" />
    <category term="HTMX" />
    <category term="Side project" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-05-31-piloter-mon-spa-intex-sans-leur-app.fr.png" />
  </entry>
</feed>

