<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <generator uri="https://bridgetownrb.com/" version="2.2.2">Bridgetown</generator>
  <link href="https://sxnlabs.com/en/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://sxnlabs.com/en/" rel="alternate" type="text/html" hreflang="en" />
  <updated>2026-09-15T14:42:59+02:00</updated>
  <id>https://sxnlabs.com/en/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="en">
    <title type="html">What AI didn&#39;t make faster</title>
    <link href="https://sxnlabs.com/en/opinion/2026/10/22/ce-que-l-ia-n-a-pas-rendu-plus-rapide/" rel="alternate" type="text/html" title="What AI didn&#39;t make faster" />
    <published>2026-10-22T09:00:00+02:00</published>
    <updated>2026-10-22T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/opinion/2026/10/22/ce-que-l-ia-n-a-pas-rendu-plus-rapide/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/opinion/2026/10/22/ce-que-l-ia-n-a-pas-rendu-plus-rapide/">&lt;p&gt;AI now writes a good share of my code, and my days are no shorter. What fills them changed: writing the code takes far less time, and everything around it moved in.&lt;/p&gt;

&lt;h2 id=&quot;what-got-faster&quot;&gt;What got faster&lt;/h2&gt;

&lt;p&gt;The first draft of a screen, a migration, tests for a business rule that is already clear, a translation, API documentation, reading a repo nobody has opened in six months. On that work the gain is clear, sometimes threefold by rough estimate. But that is the visible part of the job, and the invoice fills up elsewhere.&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.en.svg&quot; alt=&quot;Five chained steps: deciding what to build, specifying the expected behavior, writing the code, checking what comes out and what it exposes, answering for production. Only the writing step is marked faster with AI. The other four take as long as before.&quot; /&gt;
  &lt;figcaption&gt;AI shortened one step out of five, the visible one.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;deciding&quot;&gt;Deciding&lt;/h2&gt;

&lt;p&gt;On my prospecting tool, a model scores companies on how likely they are to buy software from me. It gave almost everyone the same high score. Rewriting the prompt took one evening.&lt;/p&gt;

&lt;p&gt;The rest took days: choosing which mistake costs me more, a good prospect I never see or a bad one that wastes an hour, then sorting dozens of companies by hand to have something to measure against. No assistant makes that call, because it does not know what costs you.&lt;/p&gt;

&lt;h2 id=&quot;specifying&quot;&gt;Specifying&lt;/h2&gt;

&lt;p&gt;On a management application, a demo company was shown “your subscription has been cancelled,” because the code did not tell “demo” from “stopped.” The change was one condition and one message, five minutes.&lt;/p&gt;

&lt;p&gt;The half hour before went into what the code never asks. What should a demo company read? Who can change that status? What does an administrator see when impersonating a colleague’s account? AI writes the behavior you describe very well, not the one you need.&lt;/p&gt;

&lt;h2 id=&quot;checking&quot;&gt;Checking&lt;/h2&gt;

&lt;p&gt;An assistant produces code that looks right, and that is what makes it misleading.&lt;/p&gt;

&lt;p&gt;A pull request fixes one bug and cites two neighboring issues as debts it does not address, with a link to each. Any automation that closes “linked issues” closes both, and two live bugs vanish from the tracker.&lt;/p&gt;

&lt;p&gt;A release of my e-invoicing gem passed its 317 tests and would not load for anyone who did not already have two of its dependencies. No tool pointed me to the missing check. It took knowing that the tests run in the repo while the user installs the package.&lt;/p&gt;

&lt;h2 id=&quot;securing&quot;&gt;Securing&lt;/h2&gt;

&lt;p&gt;This summer in France, the leaks kept coming. &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;The tax administration confirmed&lt;/a&gt; illegitimate access to its information system between June and August, using stolen credentials, and &lt;a href=&quot;https://www.jechange.fr/banques/news/fuite-donnees-dgfip-4750-informaticiens&quot;&gt;678,000 people are reportedly affected&lt;/a&gt; according to the ministry. &lt;a href=&quot;https://www.lemondeinformatique.fr/actualites/lire-le-ministere-de-l-education-nationale-cible-par-une-attaque-100733.html&quot;&gt;The national education ministry&lt;/a&gt; suffered an intrusion into its staff training platform that may have exposed data on staff who have worked in schools since 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 major telecom operator, warned fiber customers in August that their contact details had been read through an internal tool. Over 2025, the data protection authority received &lt;a href=&quot;https://www.cnil.fr/en/annual-report-2025&quot;&gt;6,167 data breach notifications&lt;/a&gt;, 9.5% more than in 2024.&lt;/p&gt;

&lt;p&gt;Many of these leaks take no skill at all. In the spring, &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 million accounts at ANTS&lt;/a&gt;, the agency issuing ID documents, were exposed through a flaw attributed to an IDOR: changing a file number in the URL was enough to read someone else’s, because the server never checked who it belonged to.&lt;/p&gt;

&lt;p&gt;That is exactly the kind of code that looks right. The screen works, the tests pass, and the hole only shows once someone exploits it. Reviewing every change while asking who could abuse it takes as long as it did before, and producing more code means more to review.&lt;/p&gt;

&lt;h2 id=&quot;answering-for-production&quot;&gt;Answering for production&lt;/h2&gt;

&lt;p&gt;During a recent audit of an application, the code fixes took a fraction of the time they would have two years ago. What is left on my list is operations work you do not get to replay, done by hand by someone who answers for it. And when production goes down on a Saturday night, the model is not on call.&lt;/p&gt;

&lt;h2 id=&quot;what-it-changes-when-you-commission-software&quot;&gt;What it changes when you commission software&lt;/h2&gt;

&lt;p&gt;Ran Craycraft at thoughtbot &lt;a href=&quot;https://thoughtbot.com/blog/when-to-vibe-code-an-app-and-when-to-hire-someone&quot;&gt;frames the question well&lt;/a&gt;: having an AI build a prototype now costs almost nothing, so use it to check whether an idea deserves real investment. He adds that the dangerous moment comes when the prototype works and production seems within reach. A prototype exists to learn quickly. A production application has to protect data, control access and hold up when something fails.&lt;/p&gt;

&lt;p&gt;Code was rarely the biggest line on a project’s bill, only the most visible one. When you pay a contractor, you are mostly paying for the decisions they make with you, what they check before shipping, security included, and the fact that they answer for what runs on your side. A quote promising a project “three times faster thanks to AI” assumes the project was nothing but writing code: ask what happens to the decisions, the checks, security and 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;points out&lt;/a&gt; that saying “code was never the hard part” dismisses a whole profession. Writing code still takes skill. Its cost went down, the cost of the rest did not.&lt;/p&gt;

&lt;p&gt;If you are weighing a prototype against a real project, we can look at your context together.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opinion" />
    <category term="AI" />
    <category term="Opinion" />
    <category term="Method" />
    <category term="Executives" />
    <summary type="html">AI now writes a good share of my code, and my days are no shorter. What sped up, what didn&#39;t move, and what that does to the bill for a piece of software.</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.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">The popover that flickers on hover, the tiny bug that gives everything else away</title>
    <link href="https://sxnlabs.com/en/web/2026/10/15/popover-qui-clignote-au-survol/" rel="alternate" type="text/html" title="The popover that flickers on hover, the tiny bug that gives everything else away" />
    <published>2026-10-15T09:00:00+02:00</published>
    <updated>2026-10-15T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/web/2026/10/15/popover-qui-clignote-au-survol/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/web/2026/10/15/popover-qui-clignote-au-survol/">&lt;p&gt;On a line-of-business app I maintain, a set of settings cards each show a small “?” badge that opens a help bubble on hover. Nothing exotic, a pattern you’ll find on half the web. Except the bubble flickered. You’d move the cursor toward the badge, the help would appear, vanish, reappear, in a nervous strobe that made the screen look broken. And it kind of was.&lt;/p&gt;

&lt;p&gt;This sort of detail breaks no feature. You can leave it sitting there for months; nobody files a ticket for it. But that flicker is exactly the kind of thing that plants a quiet doubt in a user’s mind: “this software isn’t finished.” The perceived quality of a business tool rides on these micro-details as much as on the big features. It is the &lt;a href=&quot;https://en.wikipedia.org/wiki/Broken_windows_theory&quot;&gt;broken windows theory&lt;/a&gt; applied to software. A visible defect left alone signals that nobody is looking after the rest, and it ends up licensing others.&lt;/p&gt;

&lt;h2 id=&quot;why-the-bubble-flickers&quot;&gt;Why the bubble flickers&lt;/h2&gt;

&lt;p&gt;The naive instinct for this kind of bubble: open on the badge’s &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt;, close on &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt;. That works as long as the cursor stays on the badge. The trouble starts the moment there’s a gap, even a single pixel, between the badge and the bubble, which there almost always is, since the bubble shows up &lt;em&gt;next to&lt;/em&gt; the badge, not on top of it.&lt;/p&gt;

&lt;p&gt;When the mouse leaves the badge heading for the bubble, it crosses that void. The browser fires &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt; on the badge → we close. The bubble disappears from under the cursor → the badge is under the mouse again → &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt; → we reopen. &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt; → close again. At the screen’s refresh rate, that’s a strobe light. The pointer sits on an unstable boundary, and each crossing fires a contradictory event.&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.en.svg&quot; alt=&quot;On the left, the hover zone stops at the badge: the cursor crosses two pixels of gap, mouseleave closes the bubble, the badge is under the cursor again and mouseenter reopens it, in a loop. On the right, badge and bubble form a single zone and a 120 ms delay makes the gap crossable.&quot; /&gt;
  &lt;figcaption&gt;Two pixels of gap are enough to make the pointer oscillate between two contradictory states.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The second problem shows up when you line several cards up side by side: sweeping the mouse across the grid opened three bubbles at once, overlapping. Two distinct bugs, one root cause: we treat hover as an instantaneous binary signal, when the user’s &lt;em&gt;intent&lt;/em&gt; has both a duration and a context.&lt;/p&gt;

&lt;h2 id=&quot;reading-an-intent-rather-than-raw-events&quot;&gt;Reading an intent rather than raw events&lt;/h2&gt;

&lt;p&gt;The right abstraction is called &lt;em&gt;hover intent&lt;/em&gt;. Instead of reacting to each isolated &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt;/&lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt;, you read an intent: “the user wants to read this help” (they linger), “they’re done” (they leave for good).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A grace delay on close.&lt;/strong&gt; On &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt;, don’t close immediately, arm a ~120 ms timer. If the cursor reaches the bubble within that window, cancel the timer. The little gap between badge and bubble becomes crossable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bubble is part of the hot zone.&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt; on the bubble itself cancels any pending close; its &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseleave&lt;/code&gt; re-arms the timer. Badge and bubble form one logical zone, even though they’re visually separate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One bubble open at a time.&lt;/strong&gt; Before opening, close whichever one is lingering. A simple module-level registry is enough, no state manager required for this.&lt;/p&gt;

&lt;p&gt;Here’s the whole thing as a dependency-free Stimulus controller:&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;// Shared registry: only one bubble open at a time.&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;// Touch screens have no hover: fall back to 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;And the HTML, where both the badge &lt;em&gt;and&lt;/em&gt; the bubble are wired to the same 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;The &lt;code class=&quot;highlighter-rouge&quot;&gt;cancelClose&lt;/code&gt; on the bubble is the keystone: it’s what makes the gap between badge and bubble crossable. Take it out and the grace delay only postpones the flicker.&lt;/p&gt;

&lt;h2 id=&quot;two-details-that-make-the-difference&quot;&gt;Two details that make the difference&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Touch has no hover.&lt;/strong&gt; On a phone or tablet, &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt; fires on the first tap and never leaves until you touch elsewhere, and the bubble stays stuck. The &lt;code class=&quot;highlighter-rouge&quot;&gt;matchMedia(&quot;(hover: hover)&quot;)&lt;/code&gt; guard neutralizes the hover logic on those devices; there, a plain tap that toggles the display is more honest. Testing a hover interaction &lt;em&gt;only&lt;/em&gt; with a mouse is a classic trap: it works perfectly on the developer’s machine and breaks in the field, where many business users are on a tablet or a phone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyboard and screen readers.&lt;/strong&gt; I wired &lt;code class=&quot;highlighter-rouge&quot;&gt;focus&lt;/code&gt;/&lt;code class=&quot;highlighter-rouge&quot;&gt;blur&lt;/code&gt; alongside hover, and tied the badge to its bubble with &lt;code class=&quot;highlighter-rouge&quot;&gt;aria-describedby&lt;/code&gt;. Contextual help that only exists on hover doesn’t exist for anyone navigating by keyboard. That took very little code, and it avoids quietly excluding a chunk of your users.&lt;/p&gt;

&lt;h2 id=&quot;what-i-take-away&quot;&gt;What I take away&lt;/h2&gt;

&lt;p&gt;The fix comes down to three ideas (grace delay, hot zone extended to the bubble, one open at a time) and about thirty lines. No library, no heavy component. What took effort was &lt;em&gt;seeing&lt;/em&gt; the problem and refusing to let it slide.&lt;/p&gt;

&lt;p&gt;A business tool is judged in use, across the thousand micro-frictions of daily work, rather than during the demo. Absorbing those frictions on the vendor’s side is invisible work: it shows up in no spec sheet, and users only notice it in the negative, the day the tool stops getting in their way.&lt;/p&gt;

&lt;p&gt;If you have an internal tool that “works” but that your teams find vaguely annoying without being able to say why, it’s often an accumulation of details like this one. We can look together at which ones are actually worth fixing.&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">A help bubble that strobes on hover, the exact cause, and the fix in thirty lines of Stimulus, touch and keyboard included.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-10-15-popover-qui-clignote-au-survol.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">An offline handwriting notebook in the browser, no native app</title>
    <link href="https://sxnlabs.com/en/ruby/2026/10/08/carnet-manuscrit-hors-ligne-tablette/" rel="alternate" type="text/html" title="An offline handwriting notebook in the browser, no native app" />
    <published>2026-10-08T09:00:00+02:00</published>
    <updated>2026-10-08T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/ruby/2026/10/08/carnet-manuscrit-hors-ligne-tablette/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/ruby/2026/10/08/carnet-manuscrit-hors-ligne-tablette/">&lt;p&gt;A business app I build offers a handwritten notes pad on tablets. Its users work in the field, often where the network is bad or simply gone, and they write with a stylus the way they would on a paper pad. Two requirements that normally call for a native app.&lt;/p&gt;

&lt;p&gt;I didn’t go native. A PWA (an installable web page, served by a plain Rails app) does the job, provided you respect three realities of the device: there isn’t always a network, the stylus is a real stylus, and it spits out data far faster than you’d expect. Each one hides a trap the tutorials never mention.&lt;/p&gt;

&lt;h2 id=&quot;the-deploy-that-wipes-the-offline-notes&quot;&gt;The deploy that wipes the offline notes&lt;/h2&gt;

&lt;p&gt;Offline-first relies on a service worker that caches pages and serves them back when the network drops. The pattern is well known. You name a cache, store the visited pages in it, and on the next deploy you rename the cache to invalidate the old content.&lt;/p&gt;

&lt;p&gt;That very rename creates a silent failure. When you bump &lt;code class=&quot;highlighter-rouge&quot;&gt;carnet-v2&lt;/code&gt; to &lt;code class=&quot;highlighter-rouge&quot;&gt;v3&lt;/code&gt;, the new service worker’s &lt;code class=&quot;highlighter-rouge&quot;&gt;activate&lt;/code&gt; event deletes the old cache. Everything in it goes too, including the notes the user had already viewed offline. The result is that someone installs the update in the morning at the depot, heads out to the field, loses the network, opens their notes… and lands on the “no connection” page. Their data existed; we had just thrown it away.&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.en.svg&quot; alt=&quot;The same deploy played twice. Without migration, activate deletes the old cache and the user out in the field lands on the offline page. With migration, install copies the entries into the new cache and the notes still open with no network.&quot; /&gt;
  &lt;figcaption&gt;Renaming the cache is what invalidates the old content, and it is also what throws away the notes already read.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The fix is to &lt;strong&gt;migrate the entries&lt;/strong&gt; from the old cache into the new one before &lt;code class=&quot;highlighter-rouge&quot;&gt;activate&lt;/code&gt; does its housekeeping:&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;// Carry entries over from any previous cache version before `activate`&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// prunes it. Renaming CACHE_NAME would otherwise wipe already-cached&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// /admin/notes pages, and the offline page relies on them to redirect&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// offline, so a user going offline right after the SW update would land&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// on the dead-end offline page until they revisited notes online.&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;// the offline page itself is always refreshed&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;The offline page stops being a dead end along the way: if the notes are cached, a script bounces straight to them instead of announcing a failure.&lt;/p&gt;

&lt;h2 id=&quot;the-stylus-that-writes-too-thick&quot;&gt;The stylus that writes “too thick”&lt;/h2&gt;

&lt;p&gt;For the stroke rendering I use &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;, which turns a sequence of points into a smoothed, variable-width outline. By default the library &lt;em&gt;simulates&lt;/em&gt; pressure from the speed of the gesture: go fast and the line thins, go slow and it thickens.&lt;/p&gt;

&lt;p&gt;Great for drawing. Disastrous for handwriting. When you write, you go slow and deliberate, exactly the regime where simulation swells the stroke. The result is pasty letters that don’t match what the hand is doing on screen.&lt;/p&gt;

&lt;p&gt;The tablet has a real stylus, which reports real pressure through &lt;code class=&quot;highlighter-rouge&quot;&gt;PointerEvent.pressure&lt;/code&gt;. So the right answer is to turn the simulation off and use hardware pressure only, when it is available:&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;// Width comes from real stylus pressure, never from velocity: simulation&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// swelled the stroke at the slow speeds typical of handwriting.&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;// Little modulation: hard presses made the stroke pasty again.&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;// Without this, smoothing catches the final point too. See below.&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;With one subtlety: &lt;code class=&quot;highlighter-rouge&quot;&gt;PointerEvent.pressure&lt;/code&gt; reads &lt;code class=&quot;highlighter-rouge&quot;&gt;0.5&lt;/code&gt; when the device reports nothing, mouse and finger included. So once per stroke we check whether any value breaks out of that constant &lt;code class=&quot;highlighter-rouge&quot;&gt;0.5&lt;/code&gt;, and width varies only then.&lt;/p&gt;

&lt;h2 id=&quot;the-line-that-does-not-stick-to-the-tip&quot;&gt;The line that does not stick to the tip&lt;/h2&gt;

&lt;p&gt;The symptom was hard to put into words. No visible slowness, no clear stutter, only the sense that the line followed the pen instead of coming out of its tip. So I measured before touching anything, with two scripts replaying real strokes, one for the geometric gap between tip and line, one for the cost of the pipeline in Chromium. Two causes came out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tip was being filtered.&lt;/strong&gt; The &lt;code class=&quot;highlighter-rouge&quot;&gt;streamline&lt;/code&gt; option smooths each point toward the previous one, and without &lt;code class=&quot;highlighter-rouge&quot;&gt;last: true&lt;/code&gt; it smooths the final one too, the point meant to sit under the pen. Over 67 real strokes, 3497 samples, the rendered tip trailed by 6.1 px on average and up to 75.8 px on fast gestures. One boolean brings the gap to zero and leaves the smoothing to the trail behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The thread froze at the worst moment.&lt;/strong&gt; Every &lt;code class=&quot;highlighter-rouge&quot;&gt;pointerup&lt;/code&gt; queued a bitmap snapshot of the note in &lt;code class=&quot;highlighter-rouge&quot;&gt;requestIdleCallback&lt;/code&gt;. Since the next letter starts about a hundred milliseconds later, the callback fired mid-stroke: every outline recomputed, a 2D canvas filled, a WebP encoded synchronously. The line froze for 150 to 950 ms, then jumped to the pen.&lt;/p&gt;

&lt;p&gt;The fix owes nothing to an algorithm. The snapshot waits for a second and a half of stillness, never fires during a gesture, and encodes off the main thread; the live stroke left the SVG holding the persisted ones, where rewriting it every frame forced the whole scene to be recomputed, for a dedicated canvas in &lt;code class=&quot;highlighter-rouge&quot;&gt;desynchronized: true&lt;/code&gt;. The worst frame while writing went from 199 to 34 ms on a light note, and from 935 to 21 ms on a note of 370 strokes.&lt;/p&gt;

&lt;p&gt;That leaves the raw sample rate. At 500 Hz most samples land less than a pixel from the previous one, adding no visible detail. Screen-space distance filtering drops them.&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: drop it&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;Distance is measured in screen pixels, not document coordinates, so density holds at any zoom, and we compare the squared value to avoid a root per point. The filter dedupes the &lt;code class=&quot;highlighter-rouge&quot;&gt;pointerrawupdate&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;pointermove&lt;/code&gt; events describing the same point along the way.&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.en.svg&quot; alt=&quot;The same stroke shown twice. On the left, the 96 raw stylus samples nearly all overlap within 1.2 pixels. On the right, 17 points are kept and the rendered outline is identical.&quot; /&gt;
  &lt;figcaption&gt;The discarded samples carried no visible detail, only per-frame computation.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;getCoalescedEvents()&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;getPredictedEvents()&lt;/code&gt; were in place long before this diagnosis and had nothing to do with it. The best-practice list was fully ticked, and the line still came off the tip.&lt;/p&gt;

&lt;h2 id=&quot;what-i-take-away-from-it&quot;&gt;What I take away from it&lt;/h2&gt;

&lt;p&gt;None of these fixes is sophisticated: a loop, two booleans, a distance comparison. What costs is identifying the right problem. The cache that wipes itself needs a genuinely bad connection, the pasty stroke a real pen in hand, the tip coming off an instrument to measure it. None of that shows up on a MacBook over Wi-Fi during development.&lt;/p&gt;

&lt;p&gt;The browser is more than capable of a near-native experience, without the cost of an app to maintain, sign and push through a store. What it asks in return is a test bench that looks like the field: a real tablet, a real pen, and airplane mode.&lt;/p&gt;

&lt;h2 id=&quot;if-this-sounds-familiar&quot;&gt;If this sounds familiar&lt;/h2&gt;

&lt;p&gt;Offline notebooks, sync that survives a flaky connection, a field app that has to hold up on an iPad that keeps falling asleep: that’s exactly the kind of friction I unblock at &lt;a href=&quot;/en/contact/?ref=carnet-offline&quot;&gt;SXN Labs&lt;/a&gt;. If you have a business use case stuck between “we’d need a native app” and “the web can’t do it”, we can look at what it actually takes.&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">Stylus writing and offline operation on a tablet, with no native app. Three traps the PWA tutorials never mention, and the fix for each of them.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-10-08-carnet-manuscrit-hors-ligne-tablette.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">Connecting a Rails application to French e-prescription through Ordoclic</title>
    <link href="https://sxnlabs.com/en/e-sante/2026/10/01/e-prescription-via-ordoclic-rails/" rel="alternate" type="text/html" title="Connecting a Rails application to French e-prescription through Ordoclic" />
    <published>2026-10-01T09:00:00+02:00</published>
    <updated>2026-10-01T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/e-sante/2026/10/01/e-prescription-via-ordoclic-rails/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/e-sante/2026/10/01/e-prescription-via-ordoclic-rails/">&lt;p&gt;ORIGAMI, the line-of-business software I build and keep in production for medical dispatch, now offers electronic prescribing to its practitioners. The connection was not built directly against the national health insurance services, but through &lt;a href=&quot;https://www.ordoclic.fr/&quot;&gt;Ordoclic&lt;/a&gt;, which exposes its e-health services to integrator vendors through an API. The CNDA certification for e-prescription, though, belongs to ORIGAMI: we ran the pre-series in our own name, with Ordoclic as the technical foundation.&lt;/p&gt;

&lt;p&gt;I am writing this path down because it is poorly known, and because smaller vendors give up on it believing they have to absorb everything themselves.&lt;/p&gt;

&lt;h2 id=&quot;software-that-has-to-prescribe&quot;&gt;Software that has to prescribe&lt;/h2&gt;

&lt;p&gt;ORIGAMI is a Rails application used by practising clinicians. At some point electronic prescribing stops being optional: paper becomes an irritant for the doctor, who wants to transmit straight to the pharmacist, and for the patient, who does not want to carry a sheet around. The software has to produce structured, signed prescriptions, file them in the secure database hosted by the national health insurance fund, and let pharmacists retrieve them from an identifier.&lt;/p&gt;

&lt;h2 id=&quot;two-paths-two-regulatory-surfaces&quot;&gt;Two paths, two regulatory surfaces&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The direct path.&lt;/strong&gt; The vendor registers with the national digital health agency, builds its PKI chain, manages its server and software certificates, integrates the national identity federation or CPS smart cards to authenticate professionals, talks to the e-prescription state service, &lt;strong&gt;builds and certifies its own prescribing engine&lt;/strong&gt; with the health authority, drug database, interaction checks, contraindications and posology included, then files its own CNDA application. This is the route the large incumbent vendors take, and it holds together: the chain is controlled end to end, with no dependency. Its entry and maintenance cost distorts the product of a one or two developer shop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The integrator API path.&lt;/strong&gt; The vendor leans on a partner already connected to the state services and already certified as a prescribing engine, which factors out the regulatory plumbing behind an API. The line-of-business software consumes those services and obtains its own CNDA certification on top of that foundation. For the doctor, the experience is the one a directly connected vendor gives: they prescribe, it goes out, the pharmacist queries the database.&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.en.svg&quot; alt=&quot;Two columns comparing the direct connection, where the vendor carries the agency registration, the PKI chain, the identity federation, the certified prescribing engine and the CNDA file, with the integrator API path, where the partner carries the first four blocks and the vendor keeps its own CNDA certification.&quot; /&gt;
  &lt;figcaption&gt;The choice turns on how much regulatory surface you carry in-house. Either way the CNDA certification stays in the vendor&#39;s name.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;what-ordoclic-carries&quot;&gt;What Ordoclic carries&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;The connection to the national health insurance services and the wider agency ecosystem, with the server certificates and health PKI chain that go with it.&lt;/li&gt;
  &lt;li&gt;Practitioner authentication through the national identity federation, soft certificates and CPS card readers, without the vendor touching those low-level flows.&lt;/li&gt;
  &lt;li&gt;Signing the prescription and filing it in the secure database, in the format pharmacists expect, with a unique QR code.&lt;/li&gt;
  &lt;li&gt;Retrieval on the dispensing side from the prescription identifier.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The prescribing engine itself&lt;/strong&gt;: certified by the health authority for ambulatory care, CE-marked as a medical device, CNDA-approved for e-prescription, backed by a maintained drug database with interaction, contraindication and posology alerts. This is the heaviest piece to carry alone: a demanding certification framework, an approved drug database to integrate, and revalidation on every change.&lt;/li&gt;
  &lt;li&gt;Support through the CNDA pre-series, the mandatory step for an integrator vendor to obtain its own certification.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-stays-with-the-vendor&quot;&gt;What stays with the vendor&lt;/h2&gt;

&lt;p&gt;Prescribing does not happen inside ORIGAMI. The doctor clicks a button that opens Ordoclic’s prescribing engine with the context already set, and that is where they prescribe, in front of the interaction and contraindication alerts of the drug database. Once they are done, ORIGAMI receives the secured prescription as a PDF by webhook, files it on the patient record, and sends it to the patient over a secure channel.&lt;/p&gt;

&lt;p&gt;That split pushes the vendor’s work to both ends. Upstream, sending the right context: which practitioner is acting, under which national registry number, in which practice context, for which patient. That flow is set up when the doctor’s account is configured and has to stay current on every prescription. Downstream, receiving the webhooks: an endpoint that answers fast, tolerates a replay without creating a duplicate, and records what it filed.&lt;/p&gt;

&lt;p&gt;The prescriber’s clinical responsibility is delegated nowhere. The engine displays the alerts, the doctor decides, and the prescription that lands back on the record carries that decision.&lt;/p&gt;

&lt;h2 id=&quot;when-the-partner-is-down&quot;&gt;When the partner is down&lt;/h2&gt;

&lt;p&gt;This is what the route costs: if Ordoclic does not answer, nobody prescribes electronically. No degraded mode will produce the prescription later, since signing and filing in the national database both go through the partner. The fallback is a paper prescription.&lt;/p&gt;

&lt;p&gt;Better to tell the prescribers before go-live. &lt;a href=&quot;/en/ruby/2026/06/04/racine-igc-sante-tls-insi/&quot;&gt;INSi&lt;/a&gt; took an outage better: care carried on, only identity verification stayed pending. Here the feature stops dead, and the availability you announce to doctors is the partner’s, not yours.&lt;/p&gt;

&lt;h2 id=&quot;the-rails-side&quot;&gt;The Rails side&lt;/h2&gt;

&lt;p&gt;The code holds no surprises: authenticated HTTP calls against the integrator API from a dedicated service layer, and an endpoint that receives the webhooks. The patterns from the rest of the application apply unchanged.&lt;/p&gt;

&lt;p&gt;The webhook is the piece that takes the most care. It arrives after the fact, on a server that did not follow the prescribing session: it has to find the right record, check the prescription was not already filed, and answer fast enough not to trigger a pointless replay.&lt;/p&gt;

&lt;p&gt;An e-prescription in the sense of the state service is a set of structured data, signed and filed in the national database, that the pharmacist retrieves by identifier. The PDF the patient receives is a copy of it, and no pharmacist ever reads it.&lt;/p&gt;

&lt;p&gt;Delivering that copy stays the vendor’s job. ORIGAMI sends an email carrying a tokenised link, and the patient confirms their identity with their initials before the document opens. Medical labs and Doctolib have already made that gesture familiar, which saves writing instructions for it.&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.en.svg&quot; alt=&quot;The path of a prescription: from ORIGAMI a button opens Ordoclic&#39;s prescribing engine with the context, the doctor prescribes there, the prescription is signed then filed in the national database and the pharmacist queries it by the QR code identifier. A return arrow brings the prescription back into ORIGAMI by webhook. Below, Ordoclic being unavailable forces a paper fallback, and the reminder that the PDF goes to the patient while the pharmacist queries the database.&quot; /&gt;
  &lt;figcaption&gt;The vendor holds both ends, the button that opens the engine and the webhook that brings the prescription back. The act itself happens elsewhere.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;the-cnda-certification-belongs-to-the-vendor&quot;&gt;The CNDA certification belongs to the vendor&lt;/h2&gt;

&lt;p&gt;The CNDA e-prescription certification obtained this way belongs to the integrator vendor, ORIGAMI here, for a given version of the software. It is earned by running the CNDA pre-series on top of Ordoclic’s engine as a compliant foundation, and it authorises releasing that version to health professionals.&lt;/p&gt;

&lt;p&gt;So it is neither an approval earned alone through a direct connection, nor one passively inherited from the partner. It is recorded, attached to a precise version, and replayed on every significant change.&lt;/p&gt;

&lt;h2 id=&quot;the-pattern-for-other-domain-vendors&quot;&gt;The pattern, for other domain vendors&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Lean on an integrator partner rather than aim for a direct connection and an in-house prescribing engine, unless you intend to become a full-time PKI and prescribing-engine vendor.&lt;/li&gt;
  &lt;li&gt;Keep the vendor responsible for what surrounds the act (context sent to the engine, webhook handling, filing on the record, secure delivery of the PDF to the patient, traceability) and delegate the regulatory plumbing, the drug database and prescribing itself.&lt;/li&gt;
  &lt;li&gt;Plan the paper fallback and announce it to prescribers, because the feature’s availability becomes the partner’s.&lt;/li&gt;
  &lt;li&gt;Treat CNDA certification as a state attached to a version, replayed on every significant change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;if-this-is-sitting-in-your-backlog&quot;&gt;If this is sitting in your backlog&lt;/h2&gt;

&lt;p&gt;If you build health software and e-prescription is on your roadmap, the integrator API route exists and works. This is the kind of work I take on at &lt;a href=&quot;/en/contact/?ref=ordoclic-eprescription&quot;&gt;SXN Labs&lt;/a&gt;: framing the need, integrating the partner into a Rails application, seeing the CNDA pre-series through, and leaving behind a system that is workable and pleasant to maintain.&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-health" />
    <category term="E-prescription" />
    <category term="Ordoclic" />
    <category term="CNDA" />
    <category term="Rails" />
    <category term="Field report" />
    <summary type="html">ORIGAMI offers French e-prescription with no direct state connection and no in-house prescribing engine. What the partner absorbs, what stays with you.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-10-01-e-prescription-via-ordoclic-rails.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">The code was right, the shipped package was not</title>
    <link href="https://sxnlabs.com/en/opensource/2026/09/24/verifier-le-package-livre-pas-le-code/" rel="alternate" type="text/html" title="The code was right, the shipped package was not" />
    <published>2026-09-24T09:00:00+02:00</published>
    <updated>2026-09-24T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/opensource/2026/09/24/verifier-le-package-livre-pas-le-code/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/opensource/2026/09/24/verifier-le-package-livre-pas-le-code/">&lt;p&gt;In late August I released version 0.9.3 of &lt;a href=&quot;/en/gems/einvoicing/&quot;&gt;einvoicing&lt;/a&gt;, my e-invoicing gem. It adds and fixes no feature. Version 0.9.2 would not load in a project that did not already have &lt;code class=&quot;highlighter-rouge&quot;&gt;bigdecimal&lt;/code&gt;, while its 317 tests passed on my machine.&lt;/p&gt;

&lt;h2 id=&quot;works-on-my-machine&quot;&gt;Works on my machine&lt;/h2&gt;

&lt;p&gt;The gem uses &lt;code class=&quot;highlighter-rouge&quot;&gt;bigdecimal&lt;/code&gt; for amounts and &lt;code class=&quot;highlighter-rouge&quot;&gt;rexml&lt;/code&gt; to read XML. Ruby used to install them itself, they now have to be declared as dependencies, and I had not declared them. On my workstation, other gems already pulled them in. Anywhere else, &lt;code class=&quot;highlighter-rouge&quot;&gt;require &quot;einvoicing&quot;&lt;/code&gt; failed right away, and Peppol validation failed as soon as it read XML.&lt;/p&gt;

&lt;p&gt;The tests run in the repository. The published package only holds the files listed in the gemspec and the declared dependencies, and it installs on a Ruby I do not control. No test looked at that 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.en.svg&quot; alt=&quot;Two blocks joined by the gem build command. On the left, the repository: the files, the gems already on the workstation, a Gemfile.lock resolved on Ruby 4.0, checked by the 317 tests. On the right, the package: only the listed files, only the declared dependencies, installed on the user&#39;s Ruby from 3.2, checked by the new check. Below, what can go missing on the way: a dependency, a file, a 3.2-compatible version.&quot; /&gt;
  &lt;figcaption&gt;The tests check the repository, the user installs the package.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;three-gaps-between-the-repository-and-the-package&quot;&gt;Three gaps between the repository and the package&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;A dependency present on the workstation and missing on the user’s side. That is the gap that shipped.&lt;/li&gt;
  &lt;li&gt;A file read at runtime but missing from the gemspec. The gem embeds an sRGB profile in every PDF/A-3: drop it from the list and every test stays green while the published gem can no longer produce a compliant invoice. I caused it on purpose, and no check saw it.&lt;/li&gt;
  &lt;li&gt;A lockfile resolved on Ruby 4.0, pinning versions that require Ruby 3.3 while the gem promises to run on 3.2.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;the-checks-added&quot;&gt;The checks added&lt;/h2&gt;

&lt;p&gt;The first builds the gem as for a release, installs it in an empty environment and loads it. It then compares, file by file, what the repository uses at runtime with what the package contains. An undeclared dependency fails the load, and a missing file is identified.&lt;/p&gt;

&lt;p&gt;The second walks the lockfile and verifies that every version accepts Ruby 3.2. A gem it could not verify, for instance when RubyGems rate-limits the requests, counts as a failure.&lt;/p&gt;

&lt;p&gt;Those checks had holes of their own, flagged in review. The first loaded the package without looking at its contents, so removing a translation file left everything green. Once fixed, it verified that a gemspec glob matched something instead of checking every expected file. I reproduced each hole by breaking the package on purpose before fixing it.&lt;/p&gt;

&lt;h2 id=&quot;beyond-ruby&quot;&gt;Beyond Ruby&lt;/h2&gt;

&lt;p&gt;The same gap exists wherever you test one thing and ship another: an installer tried on the machine that compiled it, a Docker image built with a local cache, a mobile app validated on the developer’s phone.&lt;/p&gt;

&lt;p&gt;If you have software built for you, ask what the last check before delivery runs on. If it runs on the contractor’s machine rather than on what you receive, it does not cover what you receive.&lt;/p&gt;

&lt;p&gt;Version 0.9.3 and version 0.4.0 of &lt;a href=&quot;https://github.com/sxnlabs/einvoicing-connect&quot;&gt;einvoicing-connect&lt;/a&gt; have been out since August 31. Their continuous integration now runs the tests on Ruby 3.2 and 4.0, installing the package in an empty environment on both, and the lockfile check.&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="Delivery" />
    <category term="CI" />
    <summary type="html">A gem whose 317 tests passed and that would not load without two undeclared dependencies. The checks that now verify the shipped package.</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.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">VAT is not a rate, it is a regime</title>
    <link href="https://sxnlabs.com/en/opensource/2026/09/03/tva-regime-pas-taux-facture-electronique/" rel="alternate" type="text/html" title="VAT is not a rate, it is a regime" />
    <published>2026-09-03T09:00:00+02:00</published>
    <updated>2026-09-03T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/opensource/2026/09/03/tva-regime-pas-taux-facture-electronique/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/opensource/2026/09/03/tva-regime-pas-taux-facture-electronique/">&lt;p&gt;On a PDF, VAT fits in two columns: a rate and an amount. When the rate is zero, you write the reason at the bottom of the page, in plain language, and whoever receives the invoice understands. In a structured electronic invoice, that line of text does not exist. You have to declare a regime code, and if that regime charges no VAT, a coded reason as well. Without it, the flow is rejected at the door.&lt;/p&gt;

&lt;p&gt;I maintain &lt;code class=&quot;highlighter-rouge&quot;&gt;einvoicing&lt;/code&gt;, an open source Ruby gem that generates Factur-X, UBL and CII invoices compliant with the EN 16931 standard. Recently I ran a batch of fifteen deliberately hostile invoices through it: multi-rate, reverse charge, five hundred lines, overseas rates, foreign currency. The batch went through the XSD schema, the PDF/A-3 container checks and a hand-recomputed EN 16931 rule set.&lt;/p&gt;

&lt;p&gt;The batch turned up four defects, all of them in my 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.en.svg&quot; alt=&quot;On the left, a PDF invoice where the zero rate is explained by a free-text sentence at the bottom of the page. On the right, the same case in a structured flow: a VAT category, a standardised reason code and its text, without which the flow is rejected.&quot; /&gt;
  &lt;figcaption&gt;The same exemption, written for a reader on the left and for a validator on the right.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;seven-regimes-not-one-rate&quot;&gt;Seven regimes, not one rate&lt;/h2&gt;

&lt;p&gt;The standard does not know about “a 0 % invoice”. It knows seven VAT categories, and zero can come from five of them for entirely different legal reasons:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Code&lt;/th&gt;
      &lt;th&gt;Regime&lt;/th&gt;
      &lt;th&gt;Typical case&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;Standard or reduced rate&lt;/td&gt;
      &lt;td&gt;The ordinary invoice&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Z&lt;/td&gt;
      &lt;td&gt;Zero rated&lt;/td&gt;
      &lt;td&gt;Certain supplies taxed at 0 %&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;E&lt;/td&gt;
      &lt;td&gt;Exempt&lt;/td&gt;
      &lt;td&gt;Small-business franchise, medical acts, training&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;AE&lt;/td&gt;
      &lt;td&gt;Reverse charge&lt;/td&gt;
      &lt;td&gt;Construction subcontracting&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;K&lt;/td&gt;
      &lt;td&gt;Intra-Community supply&lt;/td&gt;
      &lt;td&gt;EU customer with a valid VAT number&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;G&lt;/td&gt;
      &lt;td&gt;Export outside the EU&lt;/td&gt;
      &lt;td&gt;Sale to a Swiss or American customer&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;O&lt;/td&gt;
      &lt;td&gt;Outside the scope of VAT&lt;/td&gt;
      &lt;td&gt;Certain indemnities and subsidies&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;My gem stopped at three: standard, zero rated, reverse charge. Everything else came out as “standard”, quietly and without any visible error.&lt;/p&gt;

&lt;h2 id=&quot;the-field-paper-never-asked-for&quot;&gt;The field paper never asked for&lt;/h2&gt;

&lt;p&gt;When a category charges no VAT, the standard requires a reason: readable text and, in most cases, a European code. Five compliance rules check for it, one per category.&lt;/p&gt;

&lt;p&gt;My gem wrote none of them. As a result, &lt;strong&gt;every reverse-charge invoice it emitted was non-compliant&lt;/strong&gt;. They produced a correct PDF and schema-valid XML while being rejectable by the business rules.&lt;/p&gt;

&lt;p&gt;For reverse charge, intra-Community supply, export and out-of-scope, the reason is mechanical: there is a dedicated European code. For plain exemption, there is not. The reason depends on the article invoked, and a small business under the French franchise regime is not exempt for the same reason as a medical practice.&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;Consulting work&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;That is three lines of configuration. It still requires the software issuing the invoice to have somewhere to store them, which assumes it models the regime and not just the rate.&lt;/p&gt;

&lt;h2 id=&quot;three-cents-apart-and-the-invoice-is-refused&quot;&gt;Three cents apart, and the invoice is refused&lt;/h2&gt;

&lt;p&gt;The second defect is sneakier. A category’s VAT total can be computed two ways: by summing the rounded VAT of each line, or by applying the rate to the category’s taxable base.&lt;/p&gt;

&lt;p&gt;On a five-line invoice, both methods give the same figure. On five hundred lines, they diverge: three cents apart on one category, fifteen on another. The check is verified to the cent, and a “nearly right” invoice is refused like any other.&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.en.svg&quot; alt=&quot;A subtraction laid out over five hundred lines at 20 percent: the sum of the rounded per-line VAT gives 4,231.47 euros, the rate applied to the aggregated base gives 4,231.44 euros, and the three-cent gap is enough to fail the check.&quot; /&gt;
  &lt;figcaption&gt;Both methods are defensible. The check, however, is verified to the cent.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The standard settles on the second method: a category’s VAT amount is derived from its aggregated taxable base, not from the sum of the lines. One multiplication, one rounding, at the end.&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: category VAT = category taxable base x rate, rounded&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# to the cent. The bases therefore add up in BigDecimal, with no intermediate&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# rounding, and the rounding only happens after the 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, no rounding here&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 is 0.20 for 20%, as in the gem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Any rounding placed higher in the chain reopens the gap, which is why the defect survives line-by-line debugging, where every line taken on its own is correct.&lt;/p&gt;

&lt;p&gt;And it never shows up in acceptance testing either, because nobody tests a five-hundred-line invoice.&lt;/p&gt;

&lt;h2 id=&quot;the-rates-that-exist-anyway&quot;&gt;The rates that exist anyway&lt;/h2&gt;

&lt;p&gt;20, 10, 5.5 and 0. My list of French VAT rates stopped there, like nearly every tutorial out there. So it rejected 2.1 %, the rate for the press and reimbursable medicine. It rejected 8.5 %, the standard rate in Guadeloupe, Martinique and Réunion. It rejected the Corsican rates. All of them perfectly legal.&lt;/p&gt;

&lt;p&gt;Same family of error on VAT numbers. My validator held everyone to the French format, buyers included. An intra-Community invoice to Germany therefore failed on the customer’s VAT number, which was perfectly valid.&lt;/p&gt;

&lt;p&gt;What both bugs have in common is a reference list written from the common case. It holds until the day a user sells in Corsica or buys from Munich.&lt;/p&gt;

&lt;h2 id=&quot;why-this-becomes-a-business-problem&quot;&gt;Why this becomes a business problem&lt;/h2&gt;

&lt;p&gt;None of these cases is exotic. The French small-business franchise covers hundreds of thousands of companies. Reverse charge is the rule in construction subcontracting. The overseas rates cover several regions. Foreign currency shows up with the first customer outside the euro area.&lt;/p&gt;

&lt;p&gt;Every one of these cases is somebody’s daily routine.&lt;/p&gt;

&lt;p&gt;Today these invoices go out and nobody complains, because a human reads them on arrival and mentally fills in what is missing. The moment a machine receives them, there is no mental correction left. There is a rejection, a late payment, and a customer asking you to send it again.&lt;/p&gt;

&lt;p&gt;In France, receiving electronic invoices becomes mandatory in September 2026, and issuing them in September 2027 for small and mid-sized companies. The first rejection, though, will land before the date on the calendar.&lt;/p&gt;

&lt;h2 id=&quot;the-question-to-ask-your-software-vendor&quot;&gt;The question to ask your software vendor&lt;/h2&gt;

&lt;p&gt;Not “will you be ready for 2027?”. Everyone answers yes.&lt;/p&gt;

&lt;p&gt;Rather: “what does your software write in the exemption reason field when I issue a reverse-charge invoice?”. If there is no answer, there is no field.&lt;/p&gt;

&lt;p&gt;And to settle the doubt on a specific invoice, there is something faster than theory: &lt;a href=&quot;/en/factur-x-validator/&quot;&gt;run it through a validator&lt;/a&gt;, local analysis, no upload. If you want to review your whole chain, &lt;a href=&quot;/en/facturation-electronique-2026/&quot;&gt;the 2026 e-invoicing page&lt;/a&gt; describes the assessment I offer.&lt;/p&gt;

&lt;h2 id=&quot;what-i-take-away-from-it&quot;&gt;What I take away from it&lt;/h2&gt;

&lt;p&gt;My conclusion is a little uncomfortable. I have been writing this gem for months, I know the standard, and I still emitted non-compliant invoices the whole time without a single test flinching.&lt;/p&gt;

&lt;p&gt;The trap is not in the standard itself: schema-valid XML can be business-invalid, and nothing will tell you until somebody writes the invoices that hurt. The batch of fifteen hostile invoices cost half a day. It would have cost a great deal more in September 2027.&lt;/p&gt;

&lt;p&gt;All of it is fixed in version 0.9.0 of the gem, released right after.&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="VAT" />
    <category term="EN 16931" />
    <category term="Compliance" />
    <summary type="html">Fifteen hostile invoices run through the validator, and four defects in my own gem. What breaks in e-invoicing is the VAT regime.</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.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">When code describes a feature that doesn&#39;t exist</title>
    <link href="https://sxnlabs.com/en/programming/2026/08/24/le-code-mort-est-une-documentation-fausse/" rel="alternate" type="text/html" title="When code describes a feature that doesn&#39;t exist" />
    <published>2026-08-24T09:00:00+02:00</published>
    <updated>2026-08-24T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/programming/2026/08/24/le-code-mort-est-une-documentation-fausse/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/programming/2026/08/24/le-code-mort-est-une-documentation-fausse/">&lt;p&gt;While auditing a Rails application, I reviewed its report-sharing flow. The application generated a URL that a user could send to a recipient to grant access to a document. If the URL reached the wrong person or access needed to end, the product needed a way to invalidate it.&lt;/p&gt;

&lt;p&gt;The repository appeared to cover that case. A comment described the link as revocable, and a method regenerated its token, which should have made the previous URL unusable. The product, however, offered no revoke button, and nothing invoked the method.&lt;/p&gt;

&lt;p&gt;Reading only the code suggested an obvious conclusion: revocation was broken. Following the product raised a different possibility: the feature might never have existed.&lt;/p&gt;

&lt;h2 id=&quot;a-feature-that-existed-only-in-the-repository&quot;&gt;A feature that existed only in the repository&lt;/h2&gt;

&lt;p&gt;I restarted from the entry point. A route created the share, the controller prepared the link, and the interface let the user send it. The flow ended there, with no action to invalidate the URL and no screen for generating a replacement.&lt;/p&gt;

&lt;p&gt;The regeneration method completed no user flow. Its name and comment described a coherent capability, but the method remained disconnected from the product. That distinction matters during an audit: a broken revocation feature means users could rely on behavior that failed, while an orphaned implementation points to work that was abandoned or never finished.&lt;/p&gt;

&lt;p&gt;The likeliest explanation was a client request taken too literally. “We need to revoke a link” sounds like a need, but it already describes a solution. The underlying problem may have been a link sent to the wrong recipient, access that lasted too long, or poorly defined permissions. None of those cases had been framed, and the method remained after the subject was dropped.&lt;/p&gt;

&lt;p&gt;The same mismatch distorts estimates. A team reading the repository may price an enhancement as if revocation already existed, then discover that permissions, interface design, and the behavior of previously issued links still need to be defined.&lt;/p&gt;

&lt;h2 id=&quot;proving-the-code-is-unreachable&quot;&gt;Proving the code is unreachable&lt;/h2&gt;

&lt;p&gt;Finding no callers is not always enough in a Rails application. A callback, an interpolated name, &lt;code class=&quot;highlighter-rouge&quot;&gt;public_send&lt;/code&gt;, or a background job far from the model may still invoke a method. I checked the code from the entry points the product actually used.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;reproduce link creation and use through the interface;&lt;/li&gt;
  &lt;li&gt;trace the route, controller, views, and background jobs;&lt;/li&gt;
  &lt;li&gt;search for direct references to the method and token name;&lt;/li&gt;
  &lt;li&gt;inspect dynamic calls that a text search could miss;&lt;/li&gt;
  &lt;li&gt;instrument the method temporarily if production traffic leaves any doubt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That evidence supports a narrow deletion: the method, its comment, and tests that cover only the orphaned implementation can disappear together. Tests around the sharing flow then confirm that creating and opening a link still work. Git retains the previous implementation if the investigation turns out to be incomplete, without forcing the repository to present it as current code.&lt;/p&gt;

&lt;h2 id=&quot;delete-the-code-preserve-the-need&quot;&gt;Delete the code, preserve the need&lt;/h2&gt;

&lt;p&gt;The dead method did not make revocation available, but the need remained valid. Deleting both together would conflate two separate decisions: cleaning the repository now and deciding whether the product should support revocation.&lt;/p&gt;

&lt;p&gt;A real feature ticket still needs to define who can revoke a link, what its recipient sees after invalidation, whether the product creates a replacement automatically, and how the interface confirms the operation. Keeping an unused method answers none of those questions and merely makes the work look almost complete.&lt;/p&gt;

&lt;p&gt;The orphaned code can be deleted immediately while the need enters the roadmap with its actual scope. If revocation is prioritized later, its implementation can start from the expected user flow instead of a method discovered by accident.&lt;/p&gt;

&lt;h2 id=&quot;what-the-audit-should-conclude&quot;&gt;What the audit should conclude&lt;/h2&gt;

&lt;p&gt;The report should claim neither a fixed vulnerability nor a repaired feature. It should establish that links had never been revocable, the regeneration method had no callers, and deleting it did not change the product’s behavior.&lt;/p&gt;

&lt;p&gt;That conclusion leaves two useful artifacts: evidence supporting the cleanup and a product need that can be prioritized separately. The next reader will no longer mistake an abandoned intention for an available capability.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="programming" />
    <category term="Method" />
    <category term="Technical debt" />
    <category term="Audit" />
    <category term="Rails" />
    <summary type="html">An unused revocation method turns a missing feature into a false bug. The audit must prove the gap, delete the code, and preserve the product need.</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.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">Turbo has three scopes, not three tools</title>
    <link href="https://sxnlabs.com/en/ruby/2026/08/06/turbo-drive-frames-streams-reference/" rel="alternate" type="text/html" title="Turbo has three scopes, not three tools" />
    <published>2026-08-06T09:00:00+02:00</published>
    <updated>2026-08-06T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/ruby/2026/08/06/turbo-drive-frames-streams-reference/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/ruby/2026/08/06/turbo-drive-frames-streams-reference/">&lt;p&gt;Turbo’s documentation has a reputation for being incomplete. It isn’t, not really: every page says roughly what it should say. The problem is elsewhere. Drive, Frames and Streams are documented side by side, as three separate products, with nothing saying what connects them or how to choose. You learn the syntax of each one, and you stay stuck on the question that matters when you sit down to write: which one, here, now.&lt;/p&gt;

&lt;p&gt;When choosing, I look at two things: &lt;strong&gt;which part of the document changes&lt;/strong&gt; and &lt;strong&gt;when its target is decided&lt;/strong&gt;. Drive implicitly targets the whole page. A frame names its fragment before the request is sent. A stream lets the server name its targets in the response. That gives the three mutation &lt;strong&gt;scopes&lt;/strong&gt; used throughout this article: the whole page, a named fragment, or a set of elements designated by a response.&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;, custom actions and broadcasts are not literally replacements, but the model still holds. Morphing changes how the mutation is applied, not its scope.&lt;/p&gt;

&lt;p&gt;This is the guide I wanted three years ago. Use the model and decision table to choose a primitive, and the checklist and symptom index to debug one. The rest documents Turbo 8.0.23.&lt;/p&gt;

&lt;h2 id=&quot;versions-and-confidence-levels&quot;&gt;Versions and confidence levels&lt;/h2&gt;

&lt;p&gt;This article targets &lt;strong&gt;Turbo 8.0.23&lt;/strong&gt; and &lt;strong&gt;turbo-rails 2.0.23&lt;/strong&gt;, released on 29 January 2026, with Rails 8. Most details below come from the source and tests for those pinned versions. Callouts that depend on a particular guarantee use the following labels:&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; documented on &lt;code&gt;turbo.hotwired.dev&lt;/code&gt;. You can rely on it, and breaking it would be an acknowledged breaking change.&lt;/li&gt;
  &lt;li&gt;&lt;span class=&quot;tag tag-observe&quot;&gt;Verified&lt;/span&gt; the behaviour is present in the pinned source or tests but absent from the public API. Usable, as long as you test it yourself and re-test after an upgrade.&lt;/li&gt;
  &lt;li&gt;&lt;span class=&quot;tag tag-interne&quot;&gt;Internal&lt;/span&gt; implementation detail. Precious for understanding a bug, dangerous as a foundation. Build nothing on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Links to Turbo and turbo-rails source code point at the &lt;code class=&quot;highlighter-rouge&quot;&gt;v8.0.23&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;v2.0.23&lt;/code&gt; tags, so at frozen lines: they will still say what I claim they say long after &lt;code class=&quot;highlighter-rouge&quot;&gt;main&lt;/code&gt; has moved on.&lt;/p&gt;

&lt;nav class=&quot;sommaire&quot; aria-label=&quot;Article contents&quot;&gt;
  &lt;p class=&quot;sommaire-titre&quot;&gt;What&#39;s inside&lt;/p&gt;
  &lt;ol&gt;
    &lt;li&gt;&lt;a href=&quot;#the-model-in-one-page&quot;&gt;The model in one page&lt;/a&gt;&lt;span&gt;the three scopes, and who designates the target&lt;/span&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#which-primitive-should-you-reach-for&quot;&gt;Which primitive should you reach for?&lt;/a&gt;&lt;span&gt;the decision table, and the two tests that settle it&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;what is intercepted, the lifecycle, the cache, the 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;the identifier rule, the layout, the attributes that matter&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;the two worlds, the controller-to-view path, the eight actions&lt;/span&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#the-http-status-and-why-422&quot;&gt;The HTTP status, and why 422&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#morphing&quot;&gt;Morphing&lt;/a&gt;&lt;span&gt;what it removes, idiomorph, the pantry, field values&lt;/span&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#broadcasts&quot;&gt;Broadcasts&lt;/a&gt;&lt;span&gt;the macros, the request-id, the debounce, stream security&lt;/span&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#stimulus-and-third-party-libraries&quot;&gt;Stimulus and third-party libraries&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#network-and-offline&quot;&gt;Network and offline&lt;/a&gt;&lt;span&gt;fetch failures, service workers, Action Cable&lt;/span&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#debugging-checklist&quot;&gt;Debugging checklist&lt;/a&gt;&lt;span&gt;in this order, before you open a ticket&lt;/span&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#the-symptom-index&quot;&gt;The symptom index&lt;/a&gt;&lt;span&gt;you have a bug, you start here&lt;/span&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#what-changed-recently&quot;&gt;What changed recently&lt;/a&gt;&lt;/li&gt;
  &lt;/ol&gt;
  &lt;p class=&quot;sommaire-note&quot;&gt;If you are here because something is broken, go straight to items 11 and 12, then work your way back up to the relevant section.&lt;/p&gt;
&lt;/nav&gt;

&lt;h2 id=&quot;the-model-in-one-page&quot;&gt;The model in one 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/en/01-trois-scopes.svg?v=20260809-signature&quot; alt=&quot;Three panels comparing Turbo Drive, which replaces the whole page, Turbo Frames, which replaces a fragment designated by the client before the request leaves, and Turbo Streams, which applies commands to targets named by the server in the response.&quot; /&gt;
  &lt;figcaption&gt;The same mechanism, three scopes. What changes from one column to the next: the extent of the replacement, who designates its target, and when.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;With &lt;strong&gt;Drive&lt;/strong&gt;, the target is implicit: it is the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. It is never negotiated, so there is no identifier contract to honour. The client decides to navigate, the server responds with a page, Turbo swaps the body and merges the head.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;Frames&lt;/strong&gt;, the target is decided by the client &lt;strong&gt;before the request even leaves&lt;/strong&gt;. The emitting frame puts its identifier in a &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo-Frame&lt;/code&gt; header. On the normal extraction path, Turbo accepts only the element carrying that same identifier, either directly or through a &lt;code class=&quot;highlighter-rouge&quot;&gt;recurse&lt;/code&gt; frame. The server cannot redirect the content into another frame. It can only bypass extraction with &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt;, which turns the response into a full-page visit.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;Streams&lt;/strong&gt;, the target is written &lt;strong&gt;into the response&lt;/strong&gt;, so it is decided at the last moment, by the server. It says &lt;code class=&quot;highlighter-rouge&quot;&gt;replace the element with this id&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;append this to the end of that one&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;remove that other one&lt;/code&gt;. It can aim at several non-contiguous places, and it can do so without anyone having asked for anything, over a WebSocket.&lt;/p&gt;

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

  &lt;p&gt;Frames and Streams both rely on a DOM contract. The normal frame extraction path must find the expected identifier, either directly or through &lt;code class=&quot;highlighter-rouge&quot;&gt;recurse&lt;/code&gt;; otherwise Turbo dispatches &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;, displays &lt;code class=&quot;highlighter-rouge&quot;&gt;Content missing&lt;/code&gt; unless the event is cancelled, and throws. &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt; bypasses that path entirely with a full-page visit. A stream target must exist when the command arrives; if it does not, Turbo silently does nothing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;which-primitive-should-you-reach-for&quot;&gt;Which primitive should you reach for?&lt;/h2&gt;

&lt;p&gt;The table below is the short version. It does not rank the tools from simplest to most advanced. Start with where the state originates, then pick the first row that describes the actual need.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;What you need&lt;/th&gt;
      &lt;th&gt;The primitive&lt;/th&gt;
      &lt;th&gt;Why this choice&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;The state is purely client-side and the server has no business knowing about it&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Stimulus, without Turbo&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;A round trip to open a menu is one round trip too many&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The whole page changes and the URL must remain shareable&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Drive&lt;/strong&gt;, meaning nothing to write&lt;/td&gt;
      &lt;td&gt;It is already on. Reach for a frame only when the navigation belongs to one named region; frames can participate in history with &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-action&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;One named zone changes, and a URL returns a document containing that frame&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Frame&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Lazy loading, loading state and internal navigation come for free. The response may be a full page; Turbo extracts the matching frame. A stream would make you write the same thing by hand&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Several non-contiguous zones change in response to a user action&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;A &lt;code class=&quot;highlighter-rouge&quot;&gt;.turbo_stream&lt;/code&gt; response&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;A frame can only aim at one fragment. Slicing the page into five frames to fake a stream multiplies the requests&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The change comes from the server, with nobody clicking in this tab&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;A broadcast&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;It is the only path that can arrive without an originating request in the tab. When Turbo renders the broadcast HTML itself, its synthetic renderer has no browser session or usable &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;That leaves the case where both look possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The URL test, for frames.&lt;/strong&gt; If there is no navigable URL whose response contains the matching frame, a frame is probably not what you want. That response does not have to be a fragment-only endpoint: a full document containing the expected &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; is the normal case. A frame is a small browser with an address and a loading state, and it can also promote its navigations into Drive history with &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-action&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The recipient test, for streams.&lt;/strong&gt; If only the tab that just acted needs to change, use a &lt;code class=&quot;highlighter-rouge&quot;&gt;.turbo_stream&lt;/code&gt; response. If other tabs must receive the mutation without having made that request, use a broadcast. These are two very different uses sharing one format, and confusing them is a common source of HTML broadcast to the wrong user.&lt;/p&gt;

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

&lt;p&gt;Drive is active as soon as you load &lt;code class=&quot;highlighter-rouge&quot;&gt;@hotwired/turbo&lt;/code&gt;. There is nothing to write: despite the documentation’s chapter order, Drive covers most navigation needs.&lt;/p&gt;

&lt;h3 id=&quot;what-is-intercepted-and-what-is-not&quot;&gt;What is intercepted, and what is not&lt;/h3&gt;

&lt;p&gt;Drive intercepts unmodified primary clicks on navigatable &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; elements and navigatable form submissions, provided their destination is visitable. A visitable URL stays under the page’s &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;meta name=&quot;turbo-root&quot;&amp;gt;&lt;/code&gt; (&lt;code class=&quot;highlighter-rouge&quot;&gt;/&lt;/code&gt; by default) and its extension does not appear in &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.drive.unvisitableExtensions&lt;/code&gt; (about fifty extensions, including &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;). Cross-origin URLs are therefore excluded with the default &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-root&lt;/code&gt;. The extension list is configurable and is documented nowhere on the official site.&lt;/p&gt;

&lt;p&gt;Turbo also leaves &lt;code class=&quot;highlighter-rouge&quot;&gt;download&lt;/code&gt; links, every link whose &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; differs from &lt;code class=&quot;highlighter-rouge&quot;&gt;_self&lt;/code&gt;, and forms with &lt;code class=&quot;highlighter-rouge&quot;&gt;method=&quot;dialog&quot;&lt;/code&gt; to the browser.&lt;/p&gt;

&lt;p&gt;You switch it off case by case with &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo=&quot;false&quot;&lt;/code&gt; on the element or any of its ancestors. A &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo=&quot;true&quot;&lt;/code&gt; nested further down turns it back on.&lt;/p&gt;

&lt;h3 id=&quot;the-lifecycle&quot;&gt;The lifecycle&lt;/h3&gt;

&lt;p&gt;There is no universal sequence. This table follows an &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt; link navigation that opens a Drive visit with a fetch. It assumes a cacheable departure document and no preview snapshot. A full-page form submission makes its own request first, while a restoration from cache skips the fetch events. On the initial page load, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:load&lt;/code&gt; fires without &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:render&lt;/code&gt;. Render hooks can also run twice for a preview. The last column remains the useful one because several names are misleading.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Event&lt;/th&gt;
      &lt;th&gt;Fired on&lt;/th&gt;
      &lt;th&gt;Cancelable&lt;/th&gt;
      &lt;th&gt;What it actually lets you do&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;the clicked &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a&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;yes&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Cancel to let the browser do a plain navigation&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;yes&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;The last place you can refuse the visit&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; on a visit, the frame or form involved, the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a&amp;gt;&lt;/code&gt; on a prefetch or preload&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;yes&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;does not block the request, it pauses it&lt;/strong&gt; until you call &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;no&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Informational. &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.action&lt;/code&gt; is &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; or &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; on a visit, the frame or form involved, the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a&amp;gt;&lt;/code&gt; on a prefetch or preload&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;yes&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Cancelling stops the Visit/Frame/FormSubmission delegate from handling the response. It does not block a turbo-stream by itself: &lt;code class=&quot;highlighter-rouge&quot;&gt;StreamObserver&lt;/code&gt; ignores &lt;code class=&quot;highlighter-rouge&quot;&gt;defaultPrevented&lt;/code&gt; and may apply it from the same event&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;no&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Clean the DOM before caching. Cloning is deferred until the next 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;yes&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Same pause semantics. &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.newBody&lt;/code&gt; can be modified before rendering, &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.renderMethod&lt;/code&gt; is &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; or &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;no&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;The new body is in 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;no&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;End of the visit. On the initial load, Turbo fires it when &lt;code class=&quot;highlighter-rouge&quot;&gt;readystatechange&lt;/code&gt; reaches &lt;code class=&quot;highlighter-rouge&quot;&gt;interactive&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;complete&lt;/code&gt;; an async bundle loaded after &lt;code class=&quot;highlighter-rouge&quot;&gt;DOMContentLoaded&lt;/code&gt; can start too late and miss it&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;First surprise, &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt; comes before &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; calls &lt;code class=&quot;highlighter-rouge&quot;&gt;this.adapter.visitStarted(this)&lt;/code&gt;, which prepares the request, &lt;strong&gt;before&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;this.delegate.visitStarted(this)&lt;/code&gt;, which dispatches &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;). The actual &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch()&lt;/code&gt; still waits for interception to finish, but a listener installed from &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:visit&lt;/code&gt; has already missed &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt;. Listen to that event directly if you need to observe or modify every request.&lt;/p&gt;

&lt;p&gt;The other surprise comes down to a &lt;code class=&quot;highlighter-rouge&quot;&gt;dispatch()&lt;/code&gt; default: with no explicit &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt;, it fires on &lt;code class=&quot;highlighter-rouge&quot;&gt;document.documentElement&lt;/code&gt;, not on &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;). The events bubble, so listening on &lt;code class=&quot;highlighter-rouge&quot;&gt;document&lt;/code&gt; works, but &lt;code class=&quot;highlighter-rouge&quot;&gt;event.target&lt;/code&gt; is &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;, which matters if you filter on it. This is true for both fetch events of a Drive visit. A frame fetch targets the frame, a form submission targets the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;form&amp;gt;&lt;/code&gt;, and a prefetch or preload targets the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And the events specific to frames and forms, which slot into the same sequence:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Event&lt;/th&gt;
      &lt;th&gt;Fired on&lt;/th&gt;
      &lt;th&gt;Cancelable&lt;/th&gt;
      &lt;th&gt;What it actually lets you do&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;the frame&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;yes&quot;&gt;✓&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Pause, and &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.render&lt;/code&gt; is replaceable: this is the official entry point for plugging in another rendering engine&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;the frame&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;no&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Declared &lt;code class=&quot;highlighter-rouge&quot;&gt;cancelable: true&lt;/code&gt;, but the dispatch return value is discarded: cancelling does nothing&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;the frame&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;no&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;The frame is done&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;the &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;no&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;the &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;no&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Always &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.formSubmission&lt;/code&gt;. A response handled by &lt;code class=&quot;highlighter-rouge&quot;&gt;FormSubmission&lt;/code&gt; adds &lt;code class=&quot;highlighter-rouge&quot;&gt;success&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;fetchResponse&lt;/code&gt;; a network error adds &lt;code class=&quot;highlighter-rouge&quot;&gt;success: false&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;error&lt;/code&gt;. Those keys are absent after an abort and after the &lt;code class=&quot;highlighter-rouge&quot;&gt;Form responses must redirect&lt;/code&gt; guard rejects a non-redirected unsafe 200 response.&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;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-observe&quot;&gt;Verified&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt; does one more thing than its name suggests. Pausing it is documented in the handbook, but not the fact that Turbo re-reads the URL afterwards: &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;), and that &lt;code class=&quot;highlighter-rouge&quot;&gt;this.url&lt;/code&gt; is exactly what goes into the &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch&lt;/code&gt; a moment later. Rewriting &lt;code class=&quot;highlighter-rouge&quot;&gt;event.detail.url&lt;/code&gt; from a listener therefore really does change the URL that gets called, which is the shortest way to add a tenant prefix to every Turbo request at once.&lt;/p&gt;

  &lt;p&gt;You still have to &lt;strong&gt;do it synchronously in the listener&lt;/strong&gt;: the assignment happens &lt;em&gt;before&lt;/em&gt; the pause’s &lt;code class=&quot;highlighter-rouge&quot;&gt;await&lt;/code&gt;, so changing &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.url&lt;/code&gt; after a &lt;code class=&quot;highlighter-rouge&quot;&gt;preventDefault()&lt;/code&gt; and before &lt;code class=&quot;highlighter-rouge&quot;&gt;resume()&lt;/code&gt; has no effect. And &lt;strong&gt;assign a &lt;code class=&quot;highlighter-rouge&quot;&gt;URL&lt;/code&gt; object, not a string&lt;/strong&gt;: Turbo reads &lt;code class=&quot;highlighter-rouge&quot;&gt;this.url.href&lt;/code&gt;, and a string gives &lt;code class=&quot;highlighter-rouge&quot;&gt;undefined&lt;/code&gt;. It is not in the handbook, but it is covered by the repository’s own test suite (&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;), which makes it a sturdier guarantee than it looks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rest of the lifecycle bugs almost always come from the snapshot or the preview.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The snapshot comes from the page you are leaving, not the one you are loading.&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; fires on the live document, then &lt;code class=&quot;highlighter-rouge&quot;&gt;PageView#cacheSnapshot()&lt;/code&gt; waits until the next event-loop tick before calling &lt;code class=&quot;highlighter-rouge&quot;&gt;cloneNode(true)&lt;/code&gt;. Synchronous cleanup in this hook remains the deterministic path. The visit does not await &lt;code class=&quot;highlighter-rouge&quot;&gt;cacheSnapshot()&lt;/code&gt;, however, so replacing the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; and a Stimulus &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; can change the old DOM before it is cloned. A &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; is not necessarily too late to clean the cache.&lt;/p&gt;

&lt;p&gt;The clone &lt;strong&gt;loses the listeners&lt;/strong&gt; but &lt;strong&gt;keeps whatever injected DOM still exists at that point&lt;/strong&gt;. That is how a library can end up initialized twice on back navigation; we will return to it later.&lt;/p&gt;

&lt;p&gt;Useful detail: the clone also restores the selection of &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;select&amp;gt;&lt;/code&gt; elements (which &lt;code class=&quot;highlighter-rouge&quot;&gt;cloneNode&lt;/code&gt; loses), clears the value of every &lt;code class=&quot;highlighter-rouge&quot;&gt;input[type=password]&lt;/code&gt;, and removes &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;noscript&amp;gt;&lt;/code&gt; elements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A preview from the cache can fire &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:render&lt;/code&gt; twice, but not on back navigation.&lt;/strong&gt; On an &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; visit, Turbo uses a cached snapshot only when it is previewable and, when the URL has an anchor, contains that anchor. It renders the snapshot while the fetch continues; if the fresh response is itself rendered, a second &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:render&lt;/code&gt; follows. During the first 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; carries the &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-preview&lt;/code&gt; attribute.&lt;/p&gt;

&lt;p&gt;The nuance matters, and I had it wrong for a long time. A restoration visit with a usable snapshot issues no request and renders &lt;strong&gt;once&lt;/strong&gt;. Without a usable snapshot, it goes back to the network. &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; describe history actions, not just link clicks or form submissions: &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.visit()&lt;/code&gt; and a page refresh can produce them too.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;internal&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;It all comes down to a six-line method: &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldIssueRequest()&lt;/code&gt; returns &lt;code class=&quot;highlighter-rouge&quot;&gt;!this.hasCachedSnapshot()&lt;/code&gt; when &lt;code class=&quot;highlighter-rouge&quot;&gt;this.action == &quot;restore&quot;&lt;/code&gt;, and &lt;code class=&quot;highlighter-rouge&quot;&gt;this.willRender&lt;/code&gt; otherwise (&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;). And &lt;code class=&quot;highlighter-rouge&quot;&gt;loadCachedSnapshot&lt;/code&gt; computes its preview as &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;). No request, so no preview, so a single render.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In practice, the guard to write in a Stimulus controller is unchanged:&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;the-cache-in-numbers&quot;&gt;The cache, in numbers&lt;/h3&gt;

&lt;p&gt;The Drive cache is an LRU of &lt;strong&gt;ten snapshots&lt;/strong&gt;, in memory, in the tab. No &lt;code class=&quot;highlighter-rouge&quot;&gt;localStorage&lt;/code&gt;, no IndexedDB, no Cache API. It dies when the tab closes and on reload. Each entry keeps a clone of the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; and an index of the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, including &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;, and &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; elements. It does not store the bytes of images, stylesheets, or scripts; reusing those is the browser’s HTTP cache job.&lt;/p&gt;

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

  &lt;p&gt;&lt;strong&gt;Turbo’s cache is a perceived-latency optimization, not an offline strategy.&lt;/strong&gt; A restoration visit can display an existing snapshot without a request after the network drops, but the cache survives neither a reload nor a closed tab and cannot guarantee that the page you need is still present. Do not use it as a persistence layer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You often read that the cache is emptied “on every unsafe form submission”. That is true for half the cases only, and the other half is surprising.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;internal&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;The clearing is asymmetric. On the full-page success path, &lt;code class=&quot;highlighter-rouge&quot;&gt;clearSnapshotCache()&lt;/code&gt; is only called when &lt;code class=&quot;highlighter-rouge&quot;&gt;!formSubmission.isSafe&lt;/code&gt;, &lt;strong&gt;and&lt;/strong&gt; only inside the &lt;code class=&quot;highlighter-rouge&quot;&gt;if (responseHTML)&lt;/code&gt; branch: an unsafe submission whose response is not HTML clears nothing (&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;). On the full-page failure path, the cache is cleared when &lt;code class=&quot;highlighter-rouge&quot;&gt;responseHTML&lt;/code&gt; exists, including for a safe GET form returning an HTML 4xx or 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;). Inside a frame, the form-submission callbacks differ: an unsafe success and every failed HTTP response clear the cache even without 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;). A network error goes through &lt;code class=&quot;highlighter-rouge&quot;&gt;formSubmissionErrored()&lt;/code&gt; and does not clear it; neither does a failed link or &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; navigation.&lt;/p&gt;

  &lt;p&gt;Concretely, a GET search form clears the entire cache if it returns an HTML 4xx or 5xx as a full-page submission, or any 4xx or 5xx inside a frame. Nothing tells you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What is left for you to tune:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;What you want&lt;/th&gt;
      &lt;th&gt;How&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Never cache this page&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;Cache it but never show it as a 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;Remove an element before caching&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-temporary&lt;/code&gt; on the element&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; was &lt;strong&gt;removed in 8.0.21&lt;/strong&gt;, in January 2026, after three years of deprecation. So was &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.clearCache()&lt;/code&gt;, replaced by &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.cache.clear()&lt;/code&gt;. Both still linger in a pile of blog posts, and in Stack Overflow answers, that site where humans used to write documentation for one another.&lt;/p&gt;

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

&lt;p&gt;Since 8.0.6, configuration goes through a single object. The old setter functions (&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;) still exist but emit a console warning.&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 before the progress bar&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;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-observe&quot;&gt;Verified&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Of those five lines, two are documented on &lt;a href=&quot;https://turbo.hotwired.dev/reference/drive&quot;&gt;the Drive reference page&lt;/a&gt;: &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.drive.progressBarDelay&lt;/code&gt; and &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; and &lt;code class=&quot;highlighter-rouge&quot;&gt;forms.submitter&lt;/code&gt; appear nowhere on &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo.hotwired.dev&lt;/code&gt;. The three modes are inferred from the two equality tests in &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#submissionIsNavigatable&lt;/code&gt; plus the &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;on&quot;&lt;/code&gt; default; there is no enum in the source.&lt;/p&gt;

  &lt;p&gt;One detail that goes with it, and it bites: &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;). An unrecognized string is stored as-is, without error, and blows up later on &lt;code class=&quot;highlighter-rouge&quot;&gt;config.forms.submitter.beforeSubmit(...)&lt;/code&gt;. A typo only shows up on the first submission &lt;strong&gt;that has a submitter&lt;/strong&gt;; a submission triggered without one never makes that call.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;forms.submitter&lt;/code&gt; deserves a word. By default, Turbo sets &lt;code class=&quot;highlighter-rouge&quot;&gt;disabled&lt;/code&gt; on the submitter during submission. The control then leaves the tab order and, if it had focus, loses it; depending on the browser and assistive technology, focus may fall back to &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; and disorient someone navigating by keyboard or screen reader. With &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;aria-disabled&quot;&lt;/code&gt;, Turbo sets &lt;code class=&quot;highlighter-rouge&quot;&gt;aria-disabled=&quot;true&quot;&lt;/code&gt; and intercepts clicks on the submitter, which remains focusable. That blocks a second click handled by Turbo, not a submission triggered directly from 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;If the interface should preserve focus during submission, this line in &lt;code class=&quot;highlighter-rouge&quot;&gt;application.js&lt;/code&gt; is the better default. Just remember to style the &lt;code class=&quot;highlighter-rouge&quot;&gt;aria-disabled&lt;/code&gt; state.&lt;/p&gt;

&lt;h3 id=&quot;prefetching&quot;&gt;Prefetching&lt;/h3&gt;

&lt;p&gt;Since Turbo 8, prefetch on hover is &lt;strong&gt;on by default&lt;/strong&gt;. For an eligible link, Turbo waits 100 ms after &lt;code class=&quot;highlighter-rouge&quot;&gt;mouseenter&lt;/code&gt;, sends a GET with the &lt;code class=&quot;highlighter-rouge&quot;&gt;X-Sec-Purpose: prefetch&lt;/code&gt; header, and keeps the response in a single-entry cache for 10 seconds by default. The &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-prefetch-cache-time&lt;/code&gt; meta tag changes that TTL in milliseconds. Leaving the link before the delay expires cancels the start.&lt;/p&gt;

&lt;p&gt;Not every link qualifies. It needs a same-origin, visitable HTTP(S) URL with no &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;, unsafe method, stream, confirmation, or UJS attribute; links to the current page are excluded too. &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-prefetch&lt;/code&gt; can still cancel. Hovering any other eligible link does send a request to your server. If your GET actions are not idempotent, or if your server does not like free traffic, switch it off:&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;%# per element %&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Not to be confused with &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-preload&lt;/code&gt;, which only applies to &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a&amp;gt;&lt;/code&gt; elements and which Turbo scans after the initial load and after every view render rather than on hover. It warms the &lt;strong&gt;same Drive snapshot cache&lt;/strong&gt;; only hover prefetching has the separate one-entry, ten-second cache.&lt;/p&gt;

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

&lt;h3 id=&quot;it-all-comes-down-to-one-identifier&quot;&gt;It all comes down to one identifier&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/en/02-contrat-frame.svg?v=20260809-signature&quot; alt=&quot;The request carries the Turbo-Frame header. The response can be any HTML: Turbo first applies turbo-visit-control, then looks for the frame directly or through recurse. If no frame matches, it dispatches turbo:frame-missing.&quot; /&gt;
  &lt;figcaption&gt;After &lt;code&gt;turbo-visit-control&lt;/code&gt;, Turbo looks for the frame directly or through &lt;code&gt;recurse&lt;/code&gt;. If no frame matches, it dispatches &lt;code&gt;turbo:frame-missing&lt;/code&gt;.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The matching rule, literally, is &lt;code class=&quot;highlighter-rouge&quot;&gt;container.querySelector(&quot;turbo-frame#&quot; + CSS.escape(id))&lt;/code&gt;. No fuzzy matching, no configurable selector. In practice, have the server echo the identifier sent by the client with turbo-rails’ &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_frame_request_id&lt;/code&gt; helper:&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;The fallback is not decorative: on a full-page visit the header is absent and &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_frame_tag nil&lt;/code&gt; produces &lt;code class=&quot;highlighter-rouge&quot;&gt;id=&quot;&quot;&lt;/code&gt;, which breaks every later navigation to that frame, without a word.&lt;/p&gt;

&lt;p&gt;There is a second chance, rarely used: the &lt;code class=&quot;highlighter-rouge&quot;&gt;recurse&lt;/code&gt; attribute. If no frame with the same id is found but a &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-frame src=&quot;…&quot; recurse=&quot;my-id&quot;&amp;gt;&lt;/code&gt; is present, Turbo waits for it to load and then looks inside it. The &lt;code class=&quot;highlighter-rouge&quot;&gt;~=&lt;/code&gt; syntax belongs to Turbo’s internal CSS selector, not to the HTML attribute.&lt;/p&gt;

&lt;p&gt;And if nothing matches, the sequence is this:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;complete&lt;/code&gt; is set on the frame, &lt;strong&gt;before&lt;/strong&gt; the event.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt; is dispatched on the frame, cancelable, with &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.response&lt;/code&gt; (a raw &lt;code class=&quot;highlighter-rouge&quot;&gt;Response&lt;/code&gt;) and &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.visit(urlOrResponse, options)&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;If nobody cancels, the frame displays &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; and Turbo throws a &lt;code class=&quot;highlighter-rouge&quot;&gt;TurboFrameMissingError&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Session expiry gives a common example: the request leaves from the frame, the server redirects to &lt;code class=&quot;highlighter-rouge&quot;&gt;/login&lt;/code&gt;, and the login page obviously does not contain your frame. Rather than intercepting the event, mark the login page:&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;which emits &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 then skips frame extraction entirely and performs a full-page visit. That is the intended escape hatch, and it is far better than a global &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt; listener.&lt;/p&gt;

&lt;p&gt;A trap while we are here, and it applies to &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_refreshes_with&lt;/code&gt; further down too: this helper calls &lt;code class=&quot;highlighter-rouge&quot;&gt;provide :head&lt;/code&gt;. &lt;strong&gt;It writes nothing at the call site&lt;/strong&gt;, despite the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= %&amp;gt;&lt;/code&gt;. Without &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= yield :head %&amp;gt;&lt;/code&gt; in your layout, the meta tag never comes out and nothing happens. The &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_page_requires_reload_tag&lt;/code&gt; variant renders the tag in place if you prefer to position it yourself.&lt;/p&gt;

&lt;h3 id=&quot;the-layout-and-the-static-layout-trap&quot;&gt;The layout, and the static layout trap&lt;/h3&gt;

&lt;p&gt;turbo-rails installs this into &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;The &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_rails/frame&lt;/code&gt; layout is minimal (just &lt;code class=&quot;highlighter-rouge&quot;&gt;csrf_meta_tags&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;yield :head&lt;/code&gt;), not absent, so that &lt;code class=&quot;highlighter-rouge&quot;&gt;content_for :head&lt;/code&gt; and CSRF keep working.&lt;/p&gt;

&lt;p&gt;The trap: if you write &lt;code class=&quot;highlighter-rouge&quot;&gt;layout &quot;admin&quot;&lt;/code&gt; in a controller, you overwrite that lambda. Frame requests will then render the full layout. It &lt;strong&gt;still works&lt;/strong&gt;, because Turbo parses the response outside the live DOM and extracts the frame, but you pay the server-rendering, network and parsing cost of the entire layout on every frame request. The discarded &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; scripts are not evaluated. Scripts inside the extracted frame are activated during rendering, except those marked &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt;. You have to convert the declaration into a method:&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;Trap&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;No error, no warning, and the frame renders perfectly. Just a silent server, network and parsing bill on every request. The response’s &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; is parsed and discarded, not executed. This is the kind of regression that only shows up when you profile.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;the-attributes-that-matter&quot;&gt;The attributes that matter&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Attribute&lt;/th&gt;
      &lt;th&gt;Effect&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;Loads this URL into the frame. In the &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_frame_tag&lt;/code&gt; helper, the value goes through &lt;code class=&quot;highlighter-rouge&quot;&gt;url_for&lt;/code&gt;, so a model works.&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;Defers the first load of &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; until the frame enters the viewport. Within one &lt;code class=&quot;highlighter-rouge&quot;&gt;FrameController&lt;/code&gt;, later &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; changes load immediately. After a Drive restoration the controller is recreated and that internal flag starts over, so a new &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; outside the viewport can wait again.&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;Default target for &lt;strong&gt;descendant&lt;/strong&gt; links and forms.&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;On a link, a form or a &lt;strong&gt;submit button&lt;/strong&gt;. Overrides &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt;. The button wins over the form.&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;Breaks out of the frame: Drive treats the navigation as a full-page visit.&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;Targets the nearest ancestor frame. Added in 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;Set by Turbo during loading, along with &lt;code class=&quot;highlighter-rouge&quot;&gt;aria-busy=&quot;true&quot;&lt;/code&gt;. Useful in 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;Set after rendering a matching frame, but also before &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;. On a connected frame, a &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; change or &lt;code class=&quot;highlighter-rouge&quot;&gt;reload()&lt;/code&gt; removes it. The JS property &lt;code class=&quot;highlighter-rouge&quot;&gt;frame.complete&lt;/code&gt; reports the current loading state and does not read this attribute.&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;Cancels the current &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; fetch and stops the frame from intercepting new navigations. A descendant link or form can then fall through to Drive and navigate the full page; a form submission already in flight is not cancelled.&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;Calls &lt;code class=&quot;highlighter-rouge&quot;&gt;scrollIntoView()&lt;/code&gt; on the first element child after rendering; an empty frame does not move. &lt;code class=&quot;highlighter-rouge&quot;&gt;data-autoscroll-block&lt;/code&gt; defaults to &lt;code class=&quot;highlighter-rouge&quot;&gt;end&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;data-autoscroll-behavior&lt;/code&gt; to &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;Morphs the contents fetched by &lt;code class=&quot;highlighter-rouge&quot;&gt;reload()&lt;/code&gt;. During a page refresh rendered with morphing, Turbo keeps a compatible &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; frame outside a &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; region and calls that reload automatically.&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The table does not say everything about how a frame handles a response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a link or &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; navigation, once an ordinary HTML response reaches &lt;code class=&quot;highlighter-rouge&quot;&gt;FrameController&lt;/code&gt;, HTTP status does not choose the frame renderer.&lt;/strong&gt; The direct &lt;code class=&quot;highlighter-rouge&quot;&gt;requestSucceededWithResponse&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;requestFailedWithResponse&lt;/code&gt; callbacks both call &lt;code class=&quot;highlighter-rouge&quot;&gt;loadResponse()&lt;/code&gt;. A 4xx or 5xx with non-empty HTML and a matching frame can therefore render like a 2xx. A &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-stream&lt;/code&gt; Content-Type is intercepted earlier by &lt;code class=&quot;highlighter-rouge&quot;&gt;StreamObserver&lt;/code&gt;. Other non-HTML Content-Types and empty bodies produce no frame render and no &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;, or &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;, although &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-response&lt;/code&gt; has already fired. Cancelling that event blocks the &lt;code class=&quot;highlighter-rouge&quot;&gt;FrameController&lt;/code&gt; path, with the stream exception described in the table. With &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt;, Turbo abandons extraction and starts a full-page visit with a console warning; without a matching frame, it follows the &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt; path above.&lt;/p&gt;

&lt;p&gt;An ordinary HTML form submission takes a different path. Unless &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-response&lt;/code&gt; is cancelled, a 4xx or 5xx puts &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.success === false&lt;/code&gt; in &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:submit-end&lt;/code&gt;, clears the snapshot cache, and calls &lt;code class=&quot;highlighter-rouge&quot;&gt;loadResponse()&lt;/code&gt; on the originating frame. If the form targeted another frame, that target is therefore ignored for the failure and Turbo looks for the originating frame’s id in the response. A success uses the resolved target instead, and only clears the cache for an unsafe method.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;internal&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;One clarification, because “the status is never consulted” is slightly wrong. &lt;code class=&quot;highlighter-rouge&quot;&gt;loadResponse&lt;/code&gt; does read two status-derived properties, but for one thing only: updating &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;) After a link navigation or direct &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; assignment, the frame already points at the requested URL: if it returns an HTML 404, &lt;code class=&quot;highlighter-rouge&quot;&gt;reload()&lt;/code&gt; repeats that failing URL. A form submission does not copy its action into &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;. On a non-redirected failure, the originating frame therefore keeps its previous &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;, if it had one, and &lt;code class=&quot;highlighter-rouge&quot;&gt;reload()&lt;/code&gt; may return there; with no &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;, it loads nothing. After a redirect, &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; always takes the final URL even when its status is a failure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;A form submission inside a frame does not need to redirect.&lt;/strong&gt; The &lt;code class=&quot;highlighter-rouge&quot;&gt;Form responses must redirect to another location&lt;/code&gt; error is triggered only for an unsafe full-page submission whose final response is exactly &lt;code class=&quot;highlighter-rouge&quot;&gt;200 OK&lt;/code&gt; without a redirect. GET submissions do not have that constraint, and the guard does not cover other 2xx statuses. Inside a frame, &lt;code class=&quot;highlighter-rouge&quot;&gt;mustRedirect&lt;/code&gt; is &lt;code class=&quot;highlighter-rouge&quot;&gt;false&lt;/code&gt;: a &lt;code class=&quot;highlighter-rouge&quot;&gt;200 OK&lt;/code&gt; with a matching frame is valid.&lt;/p&gt;

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

&lt;p&gt;A &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; is an envelope. It carries an &lt;code class=&quot;highlighter-rouge&quot;&gt;action&lt;/code&gt;, a target, and a &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;There are &lt;strong&gt;two worlds&lt;/strong&gt; behind this format, and conflating them causes a fair share of Turbo Stream bugs.&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/en/03-stream-vs-broadcast.svg?v=20260809-signature&quot; alt=&quot;Two columns comparing an HTTP turbo-stream response rendered inside the request cycle and a deferred broadcast rendered outside a request, where Devise raises MissingWarden if the partial calls current_user. Both converge on the same turbo-stream element.&quot; /&gt;
  &lt;figcaption&gt;The format is identical and the tab cannot tell them apart. The whole difference was settled earlier, when the server rendered.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The first world is an &lt;strong&gt;HTTP response&lt;/strong&gt;: the user does something, the server responds with &lt;code class=&quot;highlighter-rouge&quot;&gt;text/vnd.turbo-stream.html&lt;/code&gt;, and the tab that made the request applies it. It is synchronous, it lives in the request context, &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; exists, and the HTML is rendered for that one person.&lt;/p&gt;

&lt;p&gt;The second is a &lt;strong&gt;broadcast&lt;/strong&gt;: the server publishes on an Action Cable channel, and every subscribed tab receives it. The model macros use jobs for operations that render HTML, but turbo-rails also exposes synchronous broadcast methods. When turbo-rails has to render the HTML, its synthetic renderer has no browser session or usable &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt;. A synchronous call, &lt;code class=&quot;highlighter-rouge&quot;&gt;perform_now&lt;/code&gt;, or the &lt;code class=&quot;highlighter-rouge&quot;&gt;:inline&lt;/code&gt; adapter may still see thread-local state such as &lt;code class=&quot;highlighter-rouge&quot;&gt;Current.*&lt;/code&gt;; a job actually dequeued by a worker does not inherit it. A call that already receives &lt;code class=&quot;highlighter-rouge&quot;&gt;html:&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;content:&lt;/code&gt; skips that renderer, and a refresh renders no HTML. When turbo-rails renders the partial, it produces one HTML payload and sends it unchanged to every subscriber. We come back to it below, because that is where the real problems hide.&lt;/p&gt;

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

  &lt;p&gt;The format says nothing about the context. A &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; received by a tab carries no trace of where it came from, and the application code is the same in both cases. Everything that separates the two worlds happened server-side, at render time, which is to say at the moment you decide what goes inside the &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-or-streams-what-really-separates-them&quot;&gt;Frames or Streams? What really separates them&lt;/h3&gt;

&lt;p&gt;Both can replace a piece of a page, and that is where the confusion starts.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Criterion&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;Who designates the target&lt;/td&gt;
      &lt;td&gt;The client, before the request leaves&lt;/td&gt;
      &lt;td&gt;For a targeted action, the server, in the response&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;How many zones&lt;/td&gt;
      &lt;td&gt;One, the frame’s own&lt;/td&gt;
      &lt;td&gt;Per &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt;: zero or one with &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt;, every match with &lt;code class=&quot;highlighter-rouge&quot;&gt;targets&lt;/code&gt;. A response can contain several elements&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Is the mutation a navigation&lt;/td&gt;
      &lt;td&gt;Yes: a URL returns the expected frame&lt;/td&gt;
      &lt;td&gt;No: the stream describes a mutation, even when its request has a URL&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;History and back button&lt;/td&gt;
      &lt;td&gt;Optional, with &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-action&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Lazy loading&lt;/td&gt;
      &lt;td&gt;Yes, &lt;code class=&quot;highlighter-rouge&quot;&gt;loading=&quot;lazy&quot;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Can it fire without a request&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
      &lt;td&gt;Yes, that is the broadcast&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;What happens when the target is missing&lt;/td&gt;
      &lt;td&gt;On the direct path, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;, then “Content missing” by default; &lt;code class=&quot;highlighter-rouge&quot;&gt;recurse&lt;/code&gt; may still load the expected frame, and &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt; bypasses extraction&lt;/td&gt;
      &lt;td&gt;For a targeted action, nothing at all, silently&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;What the server must know&lt;/td&gt;
      &lt;td&gt;The expected identifier, usually echoed from the &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo-Frame&lt;/code&gt; header&lt;/td&gt;
      &lt;td&gt;For a targeted action, the &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; through &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; or the CSS selector through &lt;code class=&quot;highlighter-rouge&quot;&gt;targets&lt;/code&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; has no target&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The last row is the one that should decide. On the normal direct path, a frame limits the contract to one matching fragment: the server may render a full page, but it must contain the requested identifier. &lt;code class=&quot;highlighter-rouge&quot;&gt;recurse&lt;/code&gt; can find it in a second response, while &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt; abandons that contract for a full-page visit. A targeted stream action is more &lt;strong&gt;coupled&lt;/strong&gt;: the server must know how to address its targets in the live DOM, with no way to verify that contract. &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; is the exception because it names no target.&lt;/p&gt;

&lt;p&gt;If you knew Rails before 2021, the first world will ring a bell. Answering a submission with a document that describes DOM mutations is exactly what &lt;code class=&quot;highlighter-rouge&quot;&gt;create.js.erb&lt;/code&gt; did back in the UJS days. The difference is format and vocabulary: the server returns HTML instead of arbitrary application JavaScript, and Turbo ships eight built-in actions. Custom actions extend that vocabulary explicitly, rather than opening the door to “everything jQuery knows how to do”. The main gains are readable responses and bounded intent. That is not a security boundary: Turbo deliberately activates &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; elements inside stream templates, so untrusted HTML is still untrusted HTML.&lt;/p&gt;

&lt;h3 id=&quot;the-full-path-from-controller-to-view&quot;&gt;The full path, from controller to view&lt;/h3&gt;

&lt;p&gt;A conventional controller looks like this:&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;A bare &lt;code class=&quot;highlighter-rouge&quot;&gt;format.turbo_stream&lt;/code&gt; renders a template; use it when several regions change. Inline &lt;code class=&quot;highlighter-rouge&quot;&gt;render turbo_stream:&lt;/code&gt; also accepts several concatenated tags, but its main convenience is avoiding a file for simple responses.&lt;/p&gt;

&lt;p&gt;The template is a plain ERB file that uses the stream 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;Those three lines condense almost the whole API, and each one hides something.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.prepend &quot;invoices&quot;, @invoice&lt;/code&gt; takes no partial: passing a record is enough, the builder calls &lt;code class=&quot;highlighter-rouge&quot;&gt;to_partial_path&lt;/code&gt; and renders &lt;code class=&quot;highlighter-rouge&quot;&gt;invoices/_invoice.html.erb&lt;/code&gt;. That is also why this partial must render an element with &lt;code class=&quot;highlighter-rouge&quot;&gt;id=&quot;&amp;lt;%= dom_id(invoice) %&amp;gt;&quot;&lt;/code&gt;; without it, record-based helpers cannot target the element directly later.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.update&lt;/code&gt; accepts a raw string as content, which saves a one-line partial for a counter.&lt;/p&gt;

&lt;p&gt;The block form captures whatever you write inside the &lt;strong&gt;current view context&lt;/strong&gt;. In an HTTP response it therefore sees the controller ivars, including those set by &lt;code class=&quot;highlighter-rouge&quot;&gt;before_action&lt;/code&gt; hooks. The synthetic context described above applies only when rendering a broadcast.&lt;/p&gt;

&lt;p&gt;And on the calling view side, the rule is the same as everywhere: what the stream aims at must exist with the right &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;Note &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-confirm&lt;/code&gt; on the &lt;code class=&quot;highlighter-rouge&quot;&gt;button_to&lt;/code&gt; form, the direct successor to rails-ujs’ &lt;code class=&quot;highlighter-rouge&quot;&gt;data-confirm&lt;/code&gt;. The difference is that it is pluggable: &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.forms.confirm&lt;/code&gt; accepts your own function, which finally lets you replace the native dialog with your own modal without rewriting the mechanism.&lt;/p&gt;

&lt;h3 id=&quot;eight-actions-and-morph-is-not-one-of-them&quot;&gt;Eight actions, and &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt; is not one of them&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Action&lt;/th&gt;
      &lt;th&gt;What it does&lt;/th&gt;
      &lt;th&gt;Target required&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;Adds to the end of the target’s content&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;yes&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;Adds at the beginning&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;yes&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;Inserts before the target&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;yes&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;Inserts after the target&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;yes&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;Replaces the target itself&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;yes&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;Replaces the target’s content&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-yes&quot; role=&quot;img&quot; aria-label=&quot;yes&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;Removes the target. No &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;yes&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;Refreshes the current URL through a &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; visit. No &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;no&quot;&gt;✕&lt;/span&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;That is all. There is no &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt; action. Morphing is an &lt;strong&gt;attribute&lt;/strong&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;method=&quot;morph&quot;&lt;/code&gt;, and among the targeted actions &lt;strong&gt;only &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;update&lt;/code&gt; read it&lt;/strong&gt;. Writing &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.append(&quot;x&quot;, method: :morph)&lt;/code&gt; does produce the attribute in the HTML, and the JavaScript handler for &lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; simply ignores it. &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; reads &lt;code class=&quot;highlighter-rouge&quot;&gt;method&lt;/code&gt; too, but for another reason: to choose the render mode of the page refresh there, broadcast by broadcast.&lt;/p&gt;

&lt;p&gt;The reference documents how &lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;prepend&lt;/code&gt; &lt;strong&gt;deduplicate&lt;/strong&gt;: if a direct child of the target carries the same &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; as a top-level incoming element, the old one is removed. &lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; therefore behaves like an upsert. Since 8.0.21, &lt;code class=&quot;highlighter-rouge&quot;&gt;before&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;after&lt;/code&gt; apply the same rule to the target’s siblings; that extension is not yet in the reference.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; also carries a &lt;code class=&quot;highlighter-rouge&quot;&gt;request-id&lt;/code&gt; attribute. It lets the originating tab ignore a recent request it has already applied; the client-side debounce that merges closely spaced refreshes is independent of that identifier. The broadcasts section covers both mechanisms.&lt;/p&gt;

&lt;h3 id=&quot;target-and-targets-are-not-the-same-thing&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;targets&lt;/code&gt; are not the same thing&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; takes a &lt;strong&gt;bare DOM id&lt;/strong&gt;, not a selector, and resolves to at most one element. &lt;code class=&quot;highlighter-rouge&quot;&gt;targets&lt;/code&gt; takes a &lt;strong&gt;CSS selector&lt;/strong&gt; and applies the action to every match. If both are present, &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; wins.&lt;/p&gt;

&lt;p&gt;On the Ruby side, this difference in kind is absorbed for you. If you pass a record rather than a string, the helper puts the hash in the right place:&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;- bare dom_id&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 with the #&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The trap appears when you write the selector by hand, because at that point nobody is correcting it any more: &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.replace_all &quot;invoice-row&quot;&lt;/code&gt; without the dot or the hash matches nothing, and says nothing.&lt;/p&gt;

&lt;h3 id=&quot;the-silence-when-the-target-does-not-exist&quot;&gt;The silence when the target does not exist&lt;/h3&gt;

&lt;p&gt;If &lt;code class=&quot;highlighter-rouge&quot;&gt;document.getElementById(target)&lt;/code&gt; returns &lt;code class=&quot;highlighter-rouge&quot;&gt;null&lt;/code&gt;, the getter returns an empty array, and every action iterates over that empty array. &lt;strong&gt;No warning, at any log level.&lt;/strong&gt; The stream arrives, it is visible in the Network tab, it is visible in the Rails logs, and nothing happens.&lt;/p&gt;

&lt;p&gt;The classic causes: a misspelled or accidentally pluralized &lt;code class=&quot;highlighter-rouge&quot;&gt;dom_id&lt;/code&gt;, a target living inside a &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt; or an &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; (explicitly unsupported), or a target inside a &lt;code class=&quot;highlighter-rouge&quot;&gt;loading=&quot;lazy&quot;&lt;/code&gt; frame that has not loaded yet.&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;internal&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Total silence only applies to a target that is &lt;strong&gt;present but not found&lt;/strong&gt;. If the attribute itself is missing, &lt;code class=&quot;highlighter-rouge&quot;&gt;get targetElements()&lt;/code&gt; throws &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;target or targets attribute is missing&quot;&lt;/code&gt;, and &lt;code class=&quot;highlighter-rouge&quot;&gt;get performAction()&lt;/code&gt; throws &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;unknown action&quot;&lt;/code&gt; or &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;). Those throws are caught by &lt;code class=&quot;highlighter-rouge&quot;&gt;connectedCallback&lt;/code&gt;’s &lt;code class=&quot;highlighter-rouge&quot;&gt;try/catch&lt;/code&gt; and surface as &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;), visible, but with no event and nothing on screen.&lt;/p&gt;

  &lt;p&gt;Check the console first: silence despite a received stream points to a missing target; a &lt;code class=&quot;highlighter-rouge&quot;&gt;console.error&lt;/code&gt; points to a missing attribute or an unknown action.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is a second one, sneakier. On a &lt;strong&gt;GET&lt;/strong&gt; request, Turbo sends the &lt;code class=&quot;highlighter-rouge&quot;&gt;Accept: text/vnd.turbo-stream.html&lt;/code&gt; header only if the link or the form carries &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-stream&lt;/code&gt;. Without that attribute, normal content negotiation falls back to HTML, unless the URL or params explicitly force the &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream&lt;/code&gt; format.&lt;/p&gt;

&lt;h3 id=&quot;adding-your-own-actions&quot;&gt;Adding your own actions&lt;/h3&gt;

&lt;p&gt;The mechanism is simpler than it looks and it is a good investment as soon as you catch yourself stacking streams to express a single intention.&lt;/p&gt;

&lt;p&gt;On the Ruby side, a 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;On the JavaScript side, an entry in &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.StreamActions&lt;/code&gt;, where &lt;code class=&quot;highlighter-rouge&quot;&gt;this&lt;/code&gt; is the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; element:&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;And from a model, without going through the 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;If the action is not registered on the JavaScript side, the element throws &lt;code class=&quot;highlighter-rouge&quot;&gt;unknown action&lt;/code&gt;. It is one of the rare places where Turbo is loud.&lt;/p&gt;

&lt;h2 id=&quot;the-http-status-and-why-422&quot;&gt;The HTTP status, and why 422&lt;/h2&gt;

&lt;p&gt;Turbo does not choose a renderer by reading the request headers on the way back. The fetch delegate is known before the request leaves: &lt;code class=&quot;highlighter-rouge&quot;&gt;FrameController&lt;/code&gt; for frame navigation, &lt;code class=&quot;highlighter-rouge&quot;&gt;Visit&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;FormSubmission&lt;/code&gt; for full-page navigation. The response then follows this path:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;If the &lt;code class=&quot;highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt; starts with &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; intercepts the response and applies the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; elements, regardless of status.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Otherwise the response goes back to that delegate.&lt;/strong&gt; The &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo-Frame&lt;/code&gt; header tells the server which fragment to render; Turbo does not use it as a client-side response switch.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;On the full-page path&lt;/strong&gt;, status then helps choose the normal renderer, the error renderer, or a refusal to render.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A Hotwire form that appears frozen often comes down to one line of Turbo’s 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;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;For a full-page non-GET submission, if you answer with &lt;code class=&quot;highlighter-rouge&quot;&gt;200&lt;/code&gt;, HTML and no redirect, Turbo prints &lt;code class=&quot;highlighter-rouge&quot;&gt;console.error(&quot;Form responses must redirect to another location&quot;)&lt;/code&gt; and &lt;strong&gt;renders nothing&lt;/strong&gt;. The page looks frozen. The form went out, the progress bar ran all the way, and the error messages never appeared. A submission inside a frame does not pass through this guard.&lt;/p&gt;

&lt;p&gt;The reason is given in the manual, and it is a good one: browsers have native behaviour for reloading a page that came from a POST, that “do you want to resubmit the form?” dialog, which Turbo cannot reproduce. Rather than lie about the URL, it refuses.&lt;/p&gt;

&lt;p&gt;Hence the Rails convention, which the scaffold generator already applies:&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: the response is rendered, the URL does not move&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: after update and destroy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;highlighter-rouge&quot;&gt;303&lt;/code&gt; is not a Rails whim: it is the unambiguous way to say that the redirect target must be fetched with &lt;code class=&quot;highlighter-rouge&quot;&gt;GET&lt;/code&gt;. Turbo passes &lt;code class=&quot;highlighter-rouge&quot;&gt;redirect: &quot;follow&quot;&lt;/code&gt; and lets the browser follow. Under the Fetch specification:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;303&lt;/strong&gt; replaces every method other than &lt;code class=&quot;highlighter-rouge&quot;&gt;GET&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;HEAD&lt;/code&gt; with &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; and &lt;strong&gt;302&lt;/strong&gt; force the switch to &lt;code class=&quot;highlighter-rouge&quot;&gt;GET&lt;/code&gt; &lt;strong&gt;only for POST&lt;/strong&gt;. An actual &lt;code class=&quot;highlighter-rouge&quot;&gt;DELETE&lt;/code&gt; would keep its method.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;307&lt;/strong&gt; and &lt;strong&gt;308&lt;/strong&gt; always preserve the method.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is an important turbo-rails detail here. In version 2.0.23, &lt;code class=&quot;highlighter-rouge&quot;&gt;encodeMethodIntoRequestBody&lt;/code&gt; turns methods other than &lt;code class=&quot;highlighter-rouge&quot;&gt;GET&lt;/code&gt; into a &lt;strong&gt;POST with an &lt;code class=&quot;highlighter-rouge&quot;&gt;_method&lt;/code&gt; parameter&lt;/strong&gt; before Fetch sees them (&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;); Rails’ &lt;code class=&quot;highlighter-rouge&quot;&gt;button_to method: :delete&lt;/code&gt; also submits a POST with a hidden &lt;code class=&quot;highlighter-rouge&quot;&gt;_method&lt;/code&gt;. Fetch therefore sees POST, and a 302 is followed as GET in this stack. The often-repeated story in which a Turbo &lt;code class=&quot;highlighter-rouge&quot;&gt;DELETE&lt;/code&gt; is replayed against the redirect target does not describe turbo-rails 2.0.23. &lt;code class=&quot;highlighter-rouge&quot;&gt;status: :see_other&lt;/code&gt; remains the clearest and most portable convention, especially for clients that do send a real &lt;code class=&quot;highlighter-rouge&quot;&gt;DELETE&lt;/code&gt;, but it is not repairing that particular failure mode here.&lt;/p&gt;

&lt;p&gt;A turbo-stream response short-circuits renderer selection. &lt;code class=&quot;highlighter-rouge&quot;&gt;render turbo_stream: …, status: :unprocessable_entity&lt;/code&gt; is therefore applied without trouble. The status remains observable: on a submission, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:submit-end.detail.success&lt;/code&gt; is &lt;code class=&quot;highlighter-rouge&quot;&gt;false&lt;/code&gt; for a 422 and &lt;code class=&quot;highlighter-rouge&quot;&gt;true&lt;/code&gt; for a 2xx. It also retains its meaning for tests and non-Turbo clients.&lt;/p&gt;

&lt;p&gt;A small amusing detail: the test is on &lt;code class=&quot;highlighter-rouge&quot;&gt;statusCode == 200&lt;/code&gt; exactly. A &lt;code class=&quot;highlighter-rouge&quot;&gt;201 Created&lt;/code&gt; bypasses the guard and, when the response contains a non-empty HTML body, carries on to a visit. Without that body, &lt;code class=&quot;highlighter-rouge&quot;&gt;Navigator&lt;/code&gt; proposes no visit.&lt;/p&gt;

&lt;h3 id=&quot;4xx-and-5xx-two-rendering-paths-not-one&quot;&gt;4xx and 5xx: two rendering paths, not one&lt;/h3&gt;

&lt;p&gt;This is the point I had wrong, and it is repeated in plenty of articles: everyone writes that a 4xx is rendered by the normal page renderer and a 5xx goes through the error renderer. &lt;strong&gt;That is only true for form submissions.&lt;/strong&gt;&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;What you did&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;Submit a full-page form&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;PageRenderer&lt;/code&gt;, except for an unredirected 200 from a non-GET method, which is refused&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;Click a link, or &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;The table assumes a usable HTML response. For a successful submission, &lt;code class=&quot;highlighter-rouge&quot;&gt;Navigator&lt;/code&gt; only proposes the visit when &lt;code class=&quot;highlighter-rouge&quot;&gt;responseHTML&lt;/code&gt; contains something, so an empty or non-HTML response never reaches &lt;code class=&quot;highlighter-rouge&quot;&gt;PageRenderer&lt;/code&gt;. On a visit, a non-HTML response takes the error path.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;internal&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;On a visit, the test is binary: &lt;code class=&quot;highlighter-rouge&quot;&gt;if (isSuccessful(statusCode) &amp;amp;&amp;amp; responseHTML != null)&lt;/code&gt; renders normally, &lt;code class=&quot;highlighter-rouge&quot;&gt;else&lt;/code&gt; goes to &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;), with &lt;code class=&quot;highlighter-rouge&quot;&gt;isSuccessful&lt;/code&gt; defined as &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;). The 4xx/5xx distinction exists only in &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;That deserves more than a footnote, because &lt;code class=&quot;highlighter-rouge&quot;&gt;ErrorRenderer&lt;/code&gt; is markedly more brutal than &lt;code class=&quot;highlighter-rouge&quot;&gt;PageRenderer&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;It does a &lt;code class=&quot;highlighter-rouge&quot;&gt;replaceChild&lt;/code&gt; on the &lt;strong&gt;entire&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, instead of merging it.&lt;/li&gt;
  &lt;li&gt;It walks &lt;strong&gt;every&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in the document, including head scripts (&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;), then re-activates those without &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;It does not run Bardo, the machinery that transplants permanent elements. &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; is not honoured on an error page.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And symmetrically, since a form 4xx goes through &lt;code class=&quot;highlighter-rouge&quot;&gt;PageRenderer&lt;/code&gt;, it is subject to &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldRender&lt;/code&gt;: a &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-track=&quot;reload&quot;&lt;/code&gt; mismatch on your error page will turn displaying validation errors into a full browser reload.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-piege&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Trap&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;If your 500 page is a minimal template that does not load the same assets as the rest of the site, then every server error on a link click replays the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; scripts without &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt; and throws away your permanent elements. User reports then read like “the app goes weird after an error”, which is a horrible symptom to reproduce.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;morphing&quot;&gt;Morphing&lt;/h2&gt;

&lt;p&gt;Turbo 8 introduced &lt;em&gt;page refreshes&lt;/em&gt; with morphing. The idea: rather than replacing the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, compare the old tree with the new one, and change only what differs. Scroll can be preserved; focus, text selection and transition state survive when the corresponding live node stays in place.&lt;/p&gt;

&lt;p&gt;Enable it in the 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;And here is the first trap, responsible for a good share of the “morphing does not work for me” reports: &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_refreshes_with&lt;/code&gt; calls &lt;code class=&quot;highlighter-rouge&quot;&gt;provide :head&lt;/code&gt;. &lt;strong&gt;It writes nothing where you call it.&lt;/strong&gt; Without &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= yield :head %&amp;gt;&lt;/code&gt; in your layout, the meta tags never come out, and Turbo keeps doing classic replacements, without reporting anything. The accepted values are &lt;code class=&quot;highlighter-rouge&quot;&gt;:replace&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;:morph&lt;/code&gt; for &lt;code class=&quot;highlighter-rouge&quot;&gt;method:&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;:reset&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;:preserve&lt;/code&gt; for &lt;code class=&quot;highlighter-rouge&quot;&gt;scroll:&lt;/code&gt;, anything else raises an &lt;code class=&quot;highlighter-rouge&quot;&gt;ArgumentError&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;what-actually-triggers-a-morph&quot;&gt;What actually triggers a morph&lt;/h3&gt;

&lt;p&gt;A page morph only happens for a &lt;strong&gt;page refresh&lt;/strong&gt;. When &lt;code class=&quot;highlighter-rouge&quot;&gt;PageView&lt;/code&gt; receives a Visit, the exact test comes down to two clauses: &lt;strong&gt;same &lt;code class=&quot;highlighter-rouge&quot;&gt;pathname&lt;/code&gt;&lt;/strong&gt; (the query string and the fragment do not count) &lt;strong&gt;and &lt;code class=&quot;highlighter-rouge&quot;&gt;action === &quot;replace&quot;&lt;/code&gt;&lt;/strong&gt;. With no Visit, &lt;code class=&quot;highlighter-rouge&quot;&gt;isPageRefresh()&lt;/code&gt; returns &lt;code class=&quot;highlighter-rouge&quot;&gt;true&lt;/code&gt; directly; a 4xx response to a full-page form submission takes that path and may morph when the effective method is &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without an explicit action, an unsafe submission only takes &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; when the response redirects to the exact URL of the page that contained the form. Turbo compares the final URL with &lt;code class=&quot;highlighter-rouge&quot;&gt;history.location&lt;/code&gt;, not with the form action (&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;). From &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices/42&lt;/code&gt;, a &lt;code class=&quot;highlighter-rouge&quot;&gt;PATCH /invoices/42&lt;/code&gt; that redirects to &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices/42&lt;/code&gt; morphs. From &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices&lt;/code&gt;, a &lt;code class=&quot;highlighter-rouge&quot;&gt;POST /invoices&lt;/code&gt; that redirects to &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices/42&lt;/code&gt; takes &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt;. A GET form also takes &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt;, unless its response redirects to the exact URL of the page it left.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-action=&quot;replace&quot;&lt;/code&gt; on the form or its submitter can force that action. A GET form moving from &lt;code class=&quot;highlighter-rouge&quot;&gt;/search?q=old&lt;/code&gt; to &lt;code class=&quot;highlighter-rouge&quot;&gt;/search?q=new&lt;/code&gt; can therefore morph; only the &lt;code class=&quot;highlighter-rouge&quot;&gt;pathname&lt;/code&gt; has to stay the same. The same rule applies to a &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-action=&quot;replace&quot;&lt;/code&gt; link from &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices?page=2&lt;/code&gt; to &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices?page=3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two common triggers are a form that redirects back to the page it left and a &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream action=&quot;refresh&quot;&amp;gt;&lt;/code&gt; broadcast.&lt;/p&gt;

&lt;h3 id=&quot;what-morphing-changes-line-by-line&quot;&gt;What morphing changes, line by line&lt;/h3&gt;

&lt;p&gt;The beginning and the end of the cycle are identical. It is the middle that changes, and the middle is exactly where your code lives.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;What happens&lt;/th&gt;
      &lt;th&gt;Classic render&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;when Turbo is about to cache the snapshot&lt;/td&gt;
      &lt;td&gt;same&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Snapshot cached&lt;/td&gt;
      &lt;td&gt;according to the visit policy&lt;/td&gt;
      &lt;td&gt;same&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;replaced wholesale&lt;/td&gt;
      &lt;td&gt;compared node by node by idiomorph&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Stimulus &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;/td&gt;
      &lt;td&gt;on everything&lt;/td&gt;
      &lt;td&gt;on nodes added, removed or moved; even &lt;code class=&quot;highlighter-rouge&quot;&gt;moveBefore&lt;/code&gt; produces the mutations that Stimulus interprets as a disconnect followed by a reconnect&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Inline &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; already present&lt;/td&gt;
      &lt;td&gt;re-executed unless it has &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;no&quot;&gt;✕&lt;/span&gt; never&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Genuinely new &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; eligible for evaluation&lt;/td&gt;
      &lt;td&gt;executed&lt;/td&gt;
      &lt;td&gt;executed; &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt; leaves it inert&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;transplanted by Bardo, &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; &lt;strong&gt;required&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;current node skipped when it already carries the attribute, no &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; required, Bardo never runs&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Scroll after a page refresh&lt;/td&gt;
      &lt;td&gt;preserved with &lt;code class=&quot;highlighter-rouge&quot;&gt;scroll: :preserve&lt;/code&gt;, otherwise reset&lt;/td&gt;
      &lt;td&gt;same&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Focus and text selection&lt;/td&gt;
      &lt;td&gt;lost by default; restored inside a matching permanent element&lt;/td&gt;
      &lt;td&gt;kept when the live node survives; after replacement, restoration is limited to &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;input&amp;gt;&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; elements with an &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; during a page refresh&lt;/td&gt;
      &lt;td&gt;honoured&lt;/td&gt;
      &lt;td&gt;&lt;span class=&quot;mark-no&quot; role=&quot;img&quot; aria-label=&quot;no&quot;&gt;✕&lt;/span&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;MorphingPageRenderer.shouldAutofocus&lt;/code&gt; is &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; then &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:load&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;fire&lt;/td&gt;
      &lt;td&gt;fire&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The first two rows depend on the visit path, not the renderer. After a successful unsafe form submission, Turbo clears the cache and starts a visit with &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldCacheSnapshot: false&lt;/code&gt;: no snapshot, therefore no &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; also passes that flag, with an LRU exception detailed below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:load&lt;/code&gt; does fire after a morph.&lt;/strong&gt; A refresh is a real visit. What does not replay is the inline &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags. The repository’s own test suite asserts it (&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; is controlled by snapshot paths, not by the renderer.&lt;/strong&gt; You read, and I wrote, that morphing simply removes the event. It is narrower than that. The distinction determines which cleanup code stops running.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;internal&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; hard-codes &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;). With no snapshot already cached for the current URL, a &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream action=&quot;refresh&quot;&amp;gt;&lt;/code&gt; therefore does not emit &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt;. But &lt;code class=&quot;highlighter-rouge&quot;&gt;BrowserAdapter#visitStarted()&lt;/code&gt; calls &lt;code class=&quot;highlighter-rouge&quot;&gt;loadCachedSnapshot()&lt;/code&gt; first (&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;). If the LRU already contains a previewable snapshot for that URL &lt;strong&gt;and the current document is cacheable&lt;/strong&gt;, this path calls &lt;code class=&quot;highlighter-rouge&quot;&gt;cacheSnapshot()&lt;/code&gt; without checking the flag and emits the event (&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;). After a successful form submission, &lt;code class=&quot;highlighter-rouge&quot;&gt;Navigator&lt;/code&gt; sets &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldCacheSnapshot&lt;/code&gt; from &lt;code class=&quot;highlighter-rouge&quot;&gt;formSubmission.isSafe&lt;/code&gt; and clears the cache first for an unsafe method (&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;): a POST that redirects to the same URL can therefore morph &lt;strong&gt;without&lt;/strong&gt; emitting the event. A cacheable regular &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; visit or GET form caches the snapshot and emits it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So: do not wire teardown required by the live DOM to &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; on the assumption that it precedes every morph. Keep that event for cleaning &lt;strong&gt;the snapshot when one will be taken&lt;/strong&gt;, and use morph events or the component lifecycle to protect the current document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A new script that is eligible for evaluation can run under morph.&lt;/strong&gt; idiomorph matches the old &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; with the new one by tag name, then syncs their text nodes with &lt;code class=&quot;highlighter-rouge&quot;&gt;oldNode.nodeValue = newNode.nodeValue&lt;/code&gt;. Changing the text node of an already executed script does not execute it again.&lt;/p&gt;

&lt;p&gt;A &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; produced by &lt;code class=&quot;highlighter-rouge&quot;&gt;DOMParser&lt;/code&gt; or a &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt; is inert and does not run merely because it is inserted. Turbo replaces it with an element created through &lt;code class=&quot;highlighter-rouge&quot;&gt;document.createElement(&quot;script&quot;)&lt;/code&gt; before rendering (&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;). That activation also runs on the morph path, before idiomorph compares the trees (&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;). If the morph finds a script already present, the live node stays in place and does not replay. If the script is genuinely new and does not carry &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt;, the activated element enters the DOM and runs.&lt;/p&gt;

&lt;h3 id=&quot;idiomorph-decides-by-identifiers&quot;&gt;idiomorph decides by identifiers&lt;/h3&gt;

&lt;p&gt;This is the central mechanism of morphing, and everything else depends on it: what survives a morph, what replays, what breaks in Safari and nowhere else.&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/en/04-idiomorph.svg?v=20260809-signature&quot; alt=&quot;Two columns showing the same insertion at the top of a list: with stable ids, one node is inserted and the others are untouched; without ids, matching happens by position and every row is rewritten. Below, the three conditions for an id to count, and the moveBefore versus insertBefore fork.&quot; /&gt;
  &lt;figcaption&gt;The content and visual order are identical in both columns; the new HTML is not. What changes is what survives the operation.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;An &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; is considered stable only if all three of the following conditions hold: it exists in both trees, &lt;strong&gt;the tag name is identical&lt;/strong&gt;, and it is &lt;strong&gt;duplicated in neither of them&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That third condition deserves a box around it. A single duplicated &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; in either morph root removes that &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; from the persistent set, &lt;strong&gt;in both copies&lt;/strong&gt;. For a page morph, the roots are the two &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; elements; for a stream morph, the search is limited to the target and the incoming content. A duplicate in &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; or outside the target does not affect that morph. A duplicate inside the same root can still degrade, at a distance, the matching of another element carrying that &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without stable identifiers, everything is paired by position. Adding a row at the top of a list rewrites the text of &lt;strong&gt;every&lt;/strong&gt; row rather than inserting a node. The DOM nodes remain in place, and a running CSS transition continues when the attributes or styles that triggered it do not change. The actual bug: client state can stay attached to the wrong record, or be overwritten when idiomorph synchronizes the property carrying it.&lt;/p&gt;

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

  &lt;p&gt;&lt;strong&gt;Give every list item a stable, unique &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;&lt;/strong&gt;, never reuse it, and never change the associated tag. &lt;code class=&quot;highlighter-rouge&quot;&gt;dom_id(record)&lt;/code&gt; follows those three rules; without them, morphing a list becomes unpredictable.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;node-moves-and-why-safari-differs&quot;&gt;Node moves, and why Safari differs&lt;/h3&gt;

&lt;p&gt;Here is the mechanism that explains the “it works on my machine but not on his” bug reports, and that is documented nowhere.&lt;/p&gt;

&lt;p&gt;When idiomorph reuses a node with a persistent &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; elsewhere, it moves the node without cloning it. Depending on morph order, the move happens directly from the current tree or temporarily through the pantry, a hidden &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;div&amp;gt;&lt;/code&gt; after &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. In both cases, idiomorph uses &lt;code class=&quot;highlighter-rouge&quot;&gt;moveBefore()&lt;/code&gt; when available and falls back to &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The choice between the two changes observable behaviour:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;With &lt;code class=&quot;highlighter-rouge&quot;&gt;moveBefore&lt;/code&gt; (Chromium 133 and up, Firefox 144 and up), the node &lt;strong&gt;stays connected at the DOM level&lt;/strong&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; elements do not reload, transition state is preserved, and native &lt;code class=&quot;highlighter-rouge&quot;&gt;connectedCallback&lt;/code&gt; hooks do not replay. Stimulus still observes the move mutations and runs &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; then &lt;code class=&quot;highlighter-rouge&quot;&gt;connect()&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;With &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt;, the node is &lt;strong&gt;actually disconnected then reconnected&lt;/strong&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; elements reload, CSS transitions and animations may be interrupted or restarted, native &lt;code class=&quot;highlighter-rouge&quot;&gt;connectedCallback&lt;/code&gt; hooks replay, and Stimulus also runs &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; then &lt;code class=&quot;highlighter-rouge&quot;&gt;connect()&lt;/code&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;video&amp;gt;&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; playback normally survives removal and reinsertion too; it is not a difference between the two paths.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here is the part I have seen misread more than once. &lt;code class=&quot;highlighter-rouge&quot;&gt;moveBefore&lt;/code&gt; gets described as “widely available in 2026”. That is only true if you exclude Apple.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-observe&quot;&gt;Verified&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Per &lt;code class=&quot;highlighter-rouge&quot;&gt;@mdn/browser-compat-data&lt;/code&gt; (snapshot of August 6, 2026): Chromium 133+, Firefox 144+, Opera 118, Samsung Internet 29. &lt;strong&gt;Safari, Safari iOS and the iOS WebView are all three at &lt;code class=&quot;highlighter-rouge&quot;&gt;version_added: false&lt;/code&gt;&lt;/strong&gt;, with &lt;a href=&quot;https://bugs.webkit.org/show_bug.cgi?id=281223&quot;&gt;WebKit bug 281223&lt;/a&gt; still open.&lt;/p&gt;

  &lt;p&gt;Chrome and Firefox currently distributed on iPhone also use WebKit, so they take this fallback too. Apple does &lt;a href=&quot;https://developer.apple.com/support/alternative-browser-engines/&quot;&gt;allow alternative browser engines in the European Union&lt;/a&gt;, which makes it too broad to equate all iOS traffic with WebKit forever. Safari, WebViews and the WebKit browsers in your traffic remain affected, as do Macs running Safari.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So yes, a morphing bug can be perfectly reproducible on an iPhone and nowhere to be found on your machine. It is not necessarily your code. And if your product is mostly consumed on mobile, this is not an edge case either: it may affect a material share of your users, which your own traffic data should settle.&lt;/p&gt;

&lt;h3 id=&quot;field-values-are-not-protected&quot;&gt;Field values are not protected&lt;/h3&gt;

&lt;p&gt;idiomorph does not only sync attributes, it also writes the &lt;strong&gt;live DOM properties&lt;/strong&gt; of form controls. The code is explicit:&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;// what the user had typed&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;Server-rendered HTML may contain a &lt;code class=&quot;highlighter-rouge&quot;&gt;value&lt;/code&gt; attribute, as Rails form builders often emit one, or omit it. idiomorph clears the live value in the second case and replaces it with the server value in the first. Either way, a refresh arriving mid-keystroke can discard unsaved input. The same treatment applies to &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; and the content of &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; elements.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-piege&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Trap&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;This bug is easy to miss in single-tab development, but it is not production-specific. A page refresh during typing is enough; a second tab, a broadcast or &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.visit(location.href, { action: &quot;replace&quot; })&lt;/code&gt; reproduces it locally. If you turn morphing on in an application where people enter data, treat field protection as a prerequisite, not as an improvement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;idiomorph has an &lt;code class=&quot;highlighter-rouge&quot;&gt;ignoreActiveValue&lt;/code&gt; option that excludes &lt;code class=&quot;highlighter-rouge&quot;&gt;document.activeElement&lt;/code&gt; from this synchronization. Turbo’s built-in renderers do not enable it and provide no setting for doing so. Turbo does export &lt;code class=&quot;highlighter-rouge&quot;&gt;morphElements&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;morphChildren&lt;/code&gt;, which pass the option through to idiomorph, so a custom renderer can use it.&lt;/p&gt;

&lt;p&gt;Without a custom renderer, it is on you to protect the field. The most direct lever is &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt;, which skips the morph on the element; &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-morph-element&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-morph-attribute&lt;/code&gt; allow narrower protections. Since you do not want to freeze the field permanently, you set the attribute on focus and remove it on the way out. Turbo’s test fixtures demonstrate the pattern for an &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;input&amp;gt;&lt;/code&gt;; this version extends it to &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;select&amp;gt;&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;contenteditable&lt;/code&gt; fields as well:&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;As long as the live node survives the morph, it naturally keeps focus. If that node is replaced, idiomorph explicitly restores focus and selection only for an &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;input&amp;gt;&lt;/code&gt; or a &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; &lt;strong&gt;with an &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;&lt;/strong&gt;. It offers no equivalent guarantee for a &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;select&amp;gt;&lt;/code&gt;, a &lt;code class=&quot;highlighter-rouge&quot;&gt;contenteditable&lt;/code&gt; element or a field without an &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;During a page morph, &lt;code class=&quot;highlighter-rouge&quot;&gt;autofocus&lt;/code&gt; is not honoured: &lt;code class=&quot;highlighter-rouge&quot;&gt;MorphingPageRenderer&lt;/code&gt; declares &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldAutofocus&lt;/code&gt; as &lt;code class=&quot;highlighter-rouge&quot;&gt;false&lt;/code&gt;. A frame morph takes another path; &lt;code class=&quot;highlighter-rouge&quot;&gt;MorphingFrameRenderer&lt;/code&gt; inherits from &lt;code class=&quot;highlighter-rouge&quot;&gt;FrameRenderer&lt;/code&gt;, which does apply &lt;code class=&quot;highlighter-rouge&quot;&gt;autofocus&lt;/code&gt; after rendering.&lt;/p&gt;

&lt;h3 id=&quot;the-protection-toolbox&quot;&gt;The protection toolbox&lt;/h3&gt;

&lt;p&gt;From the finest to the most 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. Protect one specific attribute&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. Protect an entire subtree&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. Reinitialize afterwards (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. The complete freeze %&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;The documentation says nothing about what follows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; does not have the same semantics depending on the render mode.&lt;/strong&gt; In classic rendering, the selector is &lt;code class=&quot;highlighter-rouge&quot;&gt;[id][data-turbo-permanent]&lt;/code&gt;: &lt;strong&gt;the &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; is mandatory&lt;/strong&gt;, and the live node is transplanted into the new body by Bardo. Under morph, it is different code with different rules.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;internal&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;Under morph, &lt;code class=&quot;highlighter-rouge&quot;&gt;beforeNodeMorphed&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;beforeNodeAdded&lt;/code&gt; handle &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; with different criteria.&lt;/p&gt;

  &lt;p&gt;The “don’t touch me” guard (&lt;code class=&quot;highlighter-rouge&quot;&gt;beforeNodeMorphed&lt;/code&gt;) tests &lt;strong&gt;the attribute on the current node&lt;/strong&gt;: no &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; is needed, and the node is left alone when it already carries &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt;. If only the incoming HTML adds the attribute, that first morph proceeds; later ones are frozen. The insertion guard does read the &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;, but with the opposite polarity: &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;). An incoming permanent element &lt;strong&gt;carrying an &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; that already exists in the document&lt;/strong&gt; is therefore refused: the existing one wins. Without an &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;, it is inserted normally.&lt;/p&gt;

  &lt;p&gt;One more thing, and it explains a lot of surprises: &lt;code class=&quot;highlighter-rouge&quot;&gt;MorphingPageRenderer&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;MorphingFrameRenderer&lt;/code&gt; both reduce Bardo to &lt;code class=&quot;highlighter-rouge&quot;&gt;async preservingPermanentElements(callback) { return await callback() }&lt;/code&gt;. Under morph, Drive’s transplantation machinery &lt;strong&gt;does not run at all&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Under morph, a current node carrying &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; freezes its subtree completely.&lt;/strong&gt; Legitimate server updates inside it will never arrive. It is the tool of last resort, not the reflex.&lt;/p&gt;

&lt;p&gt;Finally, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-morph-element&lt;/code&gt; is also dispatched for nodes about to be &lt;strong&gt;removed&lt;/strong&gt;, and in that case &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.newElement&lt;/code&gt; is &lt;code class=&quot;highlighter-rouge&quot;&gt;undefined&lt;/code&gt;. A listener that writes &lt;code class=&quot;highlighter-rouge&quot;&gt;event.detail.newElement.matches(…)&lt;/code&gt; will throw a &lt;code class=&quot;highlighter-rouge&quot;&gt;TypeError&lt;/code&gt; sooner or later.&lt;/p&gt;

&lt;h2 id=&quot;broadcasts&quot;&gt;Broadcasts&lt;/h2&gt;

&lt;p&gt;Broadcast macros take one line. Their mistakes often appear only in production.&lt;/p&gt;

&lt;h3 id=&quot;four-macros-and-one-asymmetry&quot;&gt;Four macros, and one asymmetry&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;# a single after_commit, everything goes to 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;Create&lt;/th&gt;
      &lt;th&gt;Update&lt;/th&gt;
      &lt;th&gt;Destroy&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; to &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; to &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; to &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; to &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; to &lt;strong&gt;the record’s GID stream&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;remove&lt;/code&gt; to &lt;strong&gt;the GID stream&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; to &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; to &lt;strong&gt;the GID stream&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; to &lt;strong&gt;the GID stream&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; to &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; to &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; to &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;Read the second and third rows twice. &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes&lt;/code&gt; send &lt;strong&gt;creates&lt;/strong&gt; to the collection stream, but &lt;strong&gt;updates and destroys to the record’s own stream&lt;/strong&gt;. A page that only does &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream_from &quot;cards&quot;&lt;/code&gt; will see the new cards appear and will &lt;strong&gt;never&lt;/strong&gt; see the updates or the deletions.&lt;/p&gt;

&lt;p&gt;The code is intentional, but &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream_from Card&lt;/code&gt; does not fix the asymmetry: the class produces the &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;Card&quot;&lt;/code&gt; stream, not &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;cards&quot;&lt;/code&gt;. For an index to receive all three operations, use a &lt;code class=&quot;highlighter-rouge&quot;&gt;_to&lt;/code&gt; macro and subscribe the page to that same stream. With the unsuffixed macros, it would have to subscribe to the collection stream for creates and to every record stream for updates and destroys.&lt;/p&gt;

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

  &lt;p&gt;If you want creation, update and deletion to all go to the same stream, use &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_to&lt;/code&gt;&lt;/strong&gt; for targeted DOM actions or &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes_to&lt;/code&gt;&lt;/strong&gt; for page refreshes. The unsuffixed &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes&lt;/code&gt; send creates to the collection but updates and destroys to each record’s own stream. An index subscribed only to the collection therefore sees new rows appear, then nothing else move.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Another asymmetry worth knowing: for the first three macros, deletions are &lt;strong&gt;synchronous&lt;/strong&gt; while creates and updates go through a job. That is logical (a deletion has nothing to render), but it means the deletion is &lt;strong&gt;not debounced&lt;/strong&gt;. &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes_to&lt;/code&gt; escapes this because it installs only one &lt;code class=&quot;highlighter-rouge&quot;&gt;after_commit&lt;/code&gt;: everything there is asynchronous and debounced, including the &lt;code class=&quot;highlighter-rouge&quot;&gt;destroy&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;the-full-path-of-a-refresh&quot;&gt;The full path of a refresh&lt;/h3&gt;

&lt;p&gt;The tab that writes receives its own broadcast, like every other tab. The whole request-identifier mechanism exists only to let it ignore the redundant refresh action.&lt;/p&gt;

&lt;p&gt;Every Turbo &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch&lt;/code&gt; generates a UUID, adds it to a set capped at 20 entries, and sends it as &lt;code class=&quot;highlighter-rouge&quot;&gt;X-Turbo-Request-Id&lt;/code&gt;. On the Rails side, an &lt;code class=&quot;highlighter-rouge&quot;&gt;around_action&lt;/code&gt; copies it into &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.current_request_id&lt;/code&gt;. A refresh created from that context re-emits it in the &lt;code class=&quot;highlighter-rouge&quot;&gt;request-id&lt;/code&gt; attribute by default. On reception, &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; ignores the refresh if the identifier is in its local set.&lt;/p&gt;

&lt;p&gt;The reason is a good one: the tab that wrote has &lt;strong&gt;already&lt;/strong&gt; displayed the result of its request. Replaying the refresh would cost another network round trip and could lose scroll or focus, depending on the refresh configuration and which nodes the morph keeps.&lt;/p&gt;

&lt;p&gt;This protection disappears, or acts at the wrong time, in the following cases:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Outside a request&lt;/strong&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.current_request_id&lt;/code&gt; is nil: it is a &lt;code class=&quot;highlighter-rouge&quot;&gt;thread_mattr_accessor&lt;/code&gt; populated by an &lt;code class=&quot;highlighter-rouge&quot;&gt;around_action&lt;/code&gt;. Every tab refreshes. That is generally what you want from a background job.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Without an &lt;code class=&quot;highlighter-rouge&quot;&gt;X-Turbo-Request-Id&lt;/code&gt; header&lt;/strong&gt;, for example from another HTTP client, the &lt;code class=&quot;highlighter-rouge&quot;&gt;around_action&lt;/code&gt; has nothing to copy.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The set is capped at 20.&lt;/strong&gt; Twenty new Turbo requests after the one that triggered the broadcast are enough to evict its identifier. With prefetch on hover on by default, that is no longer so theoretical.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Conversely, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.refresh&lt;/code&gt; rendered as a direct response&lt;/strong&gt; carries the request’s identifier by default, so the requesting tab ignores the action it has just received. Write &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.refresh(request_id: nil)&lt;/code&gt; if that response really should refresh the tab.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The synchronous &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcast_refresh_to&lt;/code&gt; variant is not an exception: it also goes through &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream_refresh_tag&lt;/code&gt;, whose default is &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;the-debounce-and-what-it-costs&quot;&gt;The debounce, and what it costs&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcast_refresh_later_to&lt;/code&gt; goes through a &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo::ThreadDebouncer&lt;/code&gt; memoized in &lt;code class=&quot;highlighter-rouge&quot;&gt;Thread.current&lt;/code&gt;, keyed on &lt;code class=&quot;highlighter-rouge&quot;&gt;(stream name, request_id)&lt;/code&gt;, which schedules a &lt;code class=&quot;highlighter-rouge&quot;&gt;Concurrent::ScheduledTask&lt;/code&gt; &lt;strong&gt;0.5 seconds&lt;/strong&gt; into the future. Each new call with the same key cancels the previous one. A thousand records modified in one request and broadcasting to the same stream therefore give &lt;strong&gt;one&lt;/strong&gt; broadcast; a thousand distinct GID streams still give a thousand broadcasts.&lt;/p&gt;

&lt;p&gt;On the client side, &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; is debounced at 150 ms on top of that.&lt;/p&gt;

&lt;p&gt;The downside:&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-piege&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Trap&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;A process that exits before the delay expires loses the broadcast.&lt;/strong&gt; A &lt;code class=&quot;highlighter-rouge&quot;&gt;rails runner&lt;/code&gt;, a rake task or a container can send it just fine if it stays alive for more than 0.5 s. If it exits before the &lt;code class=&quot;highlighter-rouge&quot;&gt;ScheduledTask&lt;/code&gt; runs, there is no error and no log. For a one-shot process, the synchronous &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcast_refresh_to&lt;/code&gt; avoids this timing. Adding &lt;code class=&quot;highlighter-rouge&quot;&gt;sleep Turbo::Debouncer::DEFAULT_DELAY + 0.1&lt;/code&gt; is only a workaround, not a documented contract.&lt;/p&gt;

  &lt;p&gt;This bug slips through code review easily: the migration script runs, the data is correct, and nobody notices that the open tabs did not move.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;In Rails tests that load &lt;code class=&quot;highlighter-rouge&quot;&gt;ActiveSupport::TestCase&lt;/code&gt;, there is no debounce.&lt;/strong&gt; The turbo-rails initializer then replaces the debouncer with &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo::ImmediateDebouncer&lt;/code&gt;. For N calls with the same key, your assertions count N broadcasts where production will see one. A test harness that never loads &lt;code class=&quot;highlighter-rouge&quot;&gt;ActiveSupport::TestCase&lt;/code&gt; does not get this replacement automatically.&lt;/p&gt;

&lt;h3 id=&quot;current_user-is-not-available-in-a-broadcast-render&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; is not available in a broadcast render&lt;/h3&gt;

&lt;p&gt;Every broadcast that has to render its own HTML, whether synchronous or asynchronous, goes through:&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;, along with calls that already receive &lt;code class=&quot;highlighter-rouge&quot;&gt;html:&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;content:&lt;/code&gt;, has nothing to render and skips this renderer. The other paths use an &lt;code class=&quot;highlighter-rouge&quot;&gt;ActionController::Renderer&lt;/code&gt; with a synthetic Rack environment. There is no session, no cookies, no Warden key. Mechanical consequences:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;With standard Devise, &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; raises &lt;code class=&quot;highlighter-rouge&quot;&gt;Devise::MissingWarden&lt;/code&gt;.&lt;/strong&gt; The synthetic environment has no &lt;code class=&quot;highlighter-rouge&quot;&gt;warden&lt;/code&gt; key, so the broadcast render fails; it does not quietly choose an anonymous branch. A home-grown helper may return &lt;code class=&quot;highlighter-rouge&quot;&gt;nil&lt;/code&gt;, but &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user.admin?&lt;/code&gt; would then raise a &lt;code class=&quot;highlighter-rouge&quot;&gt;NoMethodError&lt;/code&gt; anyway. Broadcast partials must not depend on request authentication helpers.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;Current.*&lt;/code&gt; is not carried into an asynchronous worker.&lt;/strong&gt; A job actually taken from the queue runs in another execution context, without the request state. &lt;code class=&quot;highlighter-rouge&quot;&gt;perform_now&lt;/code&gt; and the &lt;code class=&quot;highlighter-rouge&quot;&gt;:inline&lt;/code&gt; adapter can stay on the same thread and make the opposite appear true; synchronous broadcast variants also render on the current thread. Do not use either path as proof that a worker will see &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; is hard-coded, but it does not lose all of its configuration.&lt;/strong&gt; The renderer keeps configured view paths and, with Rails’ default &lt;code class=&quot;highlighter-rouge&quot;&gt;include_all_helpers&lt;/code&gt; setting, the application helpers. What it does not run is a &lt;code class=&quot;highlighter-rouge&quot;&gt;before_action&lt;/code&gt;, and it prepares no request ivars. A &lt;code class=&quot;highlighter-rouge&quot;&gt;helper_method&lt;/code&gt; that depends on controller-instance state is therefore still unusable.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The result of &lt;code class=&quot;highlighter-rouge&quot;&gt;_url&lt;/code&gt; helpers depends on &lt;code class=&quot;highlighter-rouge&quot;&gt;default_url_options&lt;/code&gt; and the renderer’s &lt;code class=&quot;highlighter-rouge&quot;&gt;default_env&lt;/code&gt;.&lt;/strong&gt; With no host configured, this Rails 8.0.2 renderer produces a synthetic URL on &lt;code class=&quot;highlighter-rouge&quot;&gt;example.org&lt;/code&gt;. Do not depend on that fallback: configure &lt;code class=&quot;highlighter-rouge&quot;&gt;Rails.application.routes.default_url_options[:host]&lt;/code&gt; for broadcasts. &lt;code class=&quot;highlighter-rouge&quot;&gt;_path&lt;/code&gt; helpers do not need a 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;The pattern that scales is the only one that accepts that the broadcast HTML is &lt;strong&gt;the same for every recipient&lt;/strong&gt;: broadcast a neutral partial, and have the personalized parts loaded by a frame, which each browser will go and fetch with its own 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;%# the broadcast partial, identical for everyone %&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;The alternatives: broadcast one stream per user, which is correct but O(users), or explicitly pass everything you need through &lt;code class=&quot;highlighter-rouge&quot;&gt;locals:&lt;/code&gt; and treat “this partial is broadcastable” as a strict property of the partial.&lt;/p&gt;

&lt;p&gt;It is also a practical reason to favour &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes&lt;/code&gt;: a refresh renders nothing on the server. Each browser makes its own request, with its own session. The problem disappears by construction.&lt;/p&gt;

&lt;h3 id=&quot;a-signed-stream-is-not-an-authorized-stream&quot;&gt;A signed stream is not an authorized stream&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;This line produces a &lt;code class=&quot;highlighter-rouge&quot;&gt;signed-stream-name&lt;/code&gt; that is &lt;strong&gt;identical for every user&lt;/strong&gt;. The signature prevents forgery, not reading: the name is a &lt;code class=&quot;highlighter-rouge&quot;&gt;MessageVerifier&lt;/code&gt;, so signed base64, not encrypted. Decoding the payload before &lt;code class=&quot;highlighter-rouge&quot;&gt;--&lt;/code&gt; reveals the JSON string &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;quotes&quot;&lt;/code&gt; here. With a record, that string contains its &lt;code class=&quot;highlighter-rouge&quot;&gt;to_gid_param&lt;/code&gt;, which can itself be decoded to &lt;code class=&quot;highlighter-rouge&quot;&gt;gid://app/Account/5&lt;/code&gt;. Neither decode needs your key.&lt;/p&gt;

&lt;p&gt;And &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo::StreamsChannel#subscribed&lt;/code&gt; &lt;strong&gt;accepts the subscription with no additional check whatsoever&lt;/strong&gt;. It verifies that the signature is valid, full stop.&lt;/p&gt;

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

  &lt;p&gt;&lt;strong&gt;Signing is not authorizing.&lt;/strong&gt; A &lt;code class=&quot;highlighter-rouge&quot;&gt;signed-stream-name&lt;/code&gt; proves the stream name came from your application; it proves nothing about the person subscribing to it, and it does not expire by default. Carrying the tenant inside the stream name prevents accidental cross-tenant broadcasts. It still does not authorize the subscriber: if access must be revocable, the channel has to check the current user when they subscribe.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So you have to carry the scope in the stream name:&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;That partitioning does not replace authorization. For revocable membership, write your own channel and perform the check before &lt;code class=&quot;highlighter-rouge&quot;&gt;stream_from&lt;/code&gt;. Reusing an old signed name is only exploitable while that person’s Action Cable connection is still accepted; the channel check closes that door.&lt;/p&gt;

&lt;h3 id=&quot;plumbing-traps&quot;&gt;Plumbing traps&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;In development, the default Action Cable adapter is &lt;code class=&quot;highlighter-rouge&quot;&gt;async&lt;/code&gt;, single-process.&lt;/strong&gt; A broadcast triggered from &lt;code class=&quot;highlighter-rouge&quot;&gt;bin/rails console&lt;/code&gt; will never reach a browser connected to a separately started server. Switch to &lt;code class=&quot;highlighter-rouge&quot;&gt;redis&lt;/code&gt; in &lt;code class=&quot;highlighter-rouge&quot;&gt;config/cable.yml&lt;/code&gt;, or use &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= console %&amp;gt;&lt;/code&gt; to trigger it inside the same process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;turbo-rails’ jobs inherit from &lt;code class=&quot;highlighter-rouge&quot;&gt;ActiveJob::Base&lt;/code&gt;, not from your &lt;code class=&quot;highlighter-rouge&quot;&gt;ApplicationJob&lt;/code&gt;.&lt;/strong&gt; Retry policies, callbacks and &lt;code class=&quot;highlighter-rouge&quot;&gt;queue_as&lt;/code&gt; declarations defined only on &lt;code class=&quot;highlighter-rouge&quot;&gt;ApplicationJob&lt;/code&gt; therefore do not apply. All three classes declare &lt;code class=&quot;highlighter-rouge&quot;&gt;discard_on ActiveJob::DeserializationError&lt;/code&gt;, but they do not carry the same arguments. With &lt;code class=&quot;highlighter-rouge&quot;&gt;ActionBroadcastJob&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;BroadcastJob&lt;/code&gt;, a record present in the rendering options and deleted before execution makes the job discard without a retry; the instance helpers do add &lt;code class=&quot;highlighter-rouge&quot;&gt;self&lt;/code&gt; to &lt;code class=&quot;highlighter-rouge&quot;&gt;locals:&lt;/code&gt;. The stream and target have already been converted to strings before enqueue. &lt;code class=&quot;highlighter-rouge&quot;&gt;BroadcastStreamJob&lt;/code&gt;, which handles refreshes, receives only the stream name and an already rendered &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt;, both as strings. Rails does not re-raise a deserialization error handled by &lt;code class=&quot;highlighter-rouge&quot;&gt;discard_on&lt;/code&gt;, but it logs at &lt;code class=&quot;highlighter-rouge&quot;&gt;error&lt;/code&gt; level and emits &lt;code class=&quot;highlighter-rouge&quot;&gt;discard.active_job&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;stimulus-and-third-party-libraries&quot;&gt;Stimulus and third-party libraries&lt;/h2&gt;

&lt;h3 id=&quot;the-lifecycle-precisely&quot;&gt;The lifecycle, precisely&lt;/h3&gt;

&lt;p&gt;Stimulus knows nothing about Turbo. It reacts to a &lt;code class=&quot;highlighter-rouge&quot;&gt;MutationObserver&lt;/code&gt;, in the microtask that follows each modification.&lt;/p&gt;

&lt;p&gt;On a cacheable classic visit, &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; starts the capture, but the clone is not immediate: &lt;code class=&quot;highlighter-rouge&quot;&gt;PageView#cacheSnapshot&lt;/code&gt; waits until the next event-loop tick, and the visit does not await that Promise before continuing the render. The &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; swap and the &lt;code class=&quot;highlighter-rouge&quot;&gt;MutationObserver&lt;/code&gt; microtask can therefore run &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; the clone. Synchronous cleanup in &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; may clean the deferred copy too, but that ordering is not a contract. &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; remains the public, deterministic hook for preparing the snapshot.&lt;/p&gt;

&lt;p&gt;Under morph, elements merely modified in place get &lt;strong&gt;neither &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; nor &lt;code class=&quot;highlighter-rouge&quot;&gt;connect()&lt;/code&gt;&lt;/strong&gt;. Stimulus runs those callbacks for an addition, removal or move. A move can be direct or go through the pantry, including with &lt;code class=&quot;highlighter-rouge&quot;&gt;moveBefore&lt;/code&gt;. Changing the value of &lt;code class=&quot;highlighter-rouge&quot;&gt;data-controller&lt;/code&gt; triggers them too. &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; is a separate question: it is absent after a successful unsafe form submission, and absent on &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; unless the LRU already contains a previewable snapshot for the current URL &lt;strong&gt;and the current document is cacheable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A Turbo 8.0.23 test fixture forces reconnection like this:&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;This is not a reconnection API promised by Turbo: the fixture reaches directly into Stimulus’ &lt;code class=&quot;highlighter-rouge&quot;&gt;context&lt;/code&gt; and is pinned to that version. On each &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:morph-element&lt;/code&gt;, it scans the whole &lt;code class=&quot;highlighter-rouge&quot;&gt;application.controllers&lt;/code&gt; collection, but reconnects only controllers whose element is exactly &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt;. On a large page, the cost comes from the repeated scan, not from reconnecting everything.&lt;/p&gt;

&lt;h3 id=&quot;what-breaks-and-why&quot;&gt;What breaks, and why&lt;/h3&gt;

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

  &lt;p&gt;&lt;strong&gt;Any library that injects DOM absent from the server HTML, or writes &lt;code class=&quot;highlighter-rouge&quot;&gt;class&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;style&lt;/code&gt; at runtime, can conflict with morphing.&lt;/strong&gt; Whether it breaks depends on what the server returns and whether the library can reconcile or rebuild its state. It is not automatically incompatible, but that surface needs explicit protection or lifecycle handling.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Library&lt;/th&gt;
      &lt;th&gt;What breaks&lt;/th&gt;
      &lt;th&gt;The cause&lt;/th&gt;
      &lt;th&gt;The fix&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;The chart disappears, a “Loading…” stays behind&lt;/td&gt;
      &lt;td&gt;Chartkick listens for &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-render&lt;/code&gt; and destroys every chart, including when &lt;code class=&quot;highlighter-rouge&quot;&gt;renderMethod&lt;/code&gt; is &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt;. Raw Chart.js does not do this on its own. The &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; that would recreate the chart does not re-execute&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;Chartkick.config.autoDestroy = false&lt;/code&gt;, then redraw on &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;Elements become invisible again, the &lt;code class=&quot;highlighter-rouge&quot;&gt;:class&lt;/code&gt; bindings drop&lt;/td&gt;
      &lt;td&gt;Alpine removes &lt;code class=&quot;highlighter-rouge&quot;&gt;x-cloak&lt;/code&gt; on init and writes &lt;code class=&quot;highlighter-rouge&quot;&gt;class&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;style.display&lt;/code&gt; at runtime. The server HTML knows nothing about them, the morph puts them back&lt;/td&gt;
      &lt;td&gt;Cancel &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-morph-attribute&lt;/code&gt; for &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; and &lt;code class=&quot;highlighter-rouge&quot;&gt;style&lt;/code&gt; on &lt;code class=&quot;highlighter-rouge&quot;&gt;[x-data]&lt;/code&gt; subtrees&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Tom Select, Select2, Choices&lt;/td&gt;
      &lt;td&gt;The widget disappears, the selection reverts&lt;/td&gt;
      &lt;td&gt;The injected &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;div&amp;gt;&lt;/code&gt; is not in the server HTML, so it is removed. The original &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;select&amp;gt;&lt;/code&gt; survives, so there is no reconnection&lt;/td&gt;
      &lt;td&gt;Server-render a stable wrapper containing both the control and the injected DOM, then put an &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; on that parent. Otherwise tear down before its morph and reinitialize afterwards&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Leaflet, Mapbox GL&lt;/td&gt;
      &lt;td&gt;Dead map, grey tiles, or &lt;code class=&quot;highlighter-rouge&quot;&gt;Map container is already initialized&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;The library’s injected internal DOM is removed while the container survives, so &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; does not run&lt;/td&gt;
      &lt;td&gt;Destroy before the container morphs, reinitialize on &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:morph-element&lt;/code&gt;, then call &lt;code class=&quot;highlighter-rouge&quot;&gt;invalidateSize()&lt;/code&gt; with Leaflet or &lt;code class=&quot;highlighter-rouge&quot;&gt;resize()&lt;/code&gt; with 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; opened with &lt;code class=&quot;highlighter-rouge&quot;&gt;showModal()&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;The page becomes unusable&lt;/td&gt;
      &lt;td&gt;The morph rewrites the content but does not reset the browser’s &lt;em&gt;top layer&lt;/em&gt;&lt;/td&gt;
      &lt;td&gt;Open, unresolved ticket. Close the dialog on &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-render&lt;/code&gt;, or exclude it from the 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;Panels open or close on their own for every viewer&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;open&lt;/code&gt; is synced like an ordinary attribute&lt;/td&gt;
      &lt;td&gt;Cancel &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-morph-attribute&lt;/code&gt; for &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;Broadcasts are lost, or the UI thinks the cable is offline&lt;/td&gt;
      &lt;td&gt;Reparenting through the &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt; fallback unsubscribes it; an in-place morph can remove the runtime &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt; attribute without reconnecting it&lt;/td&gt;
      &lt;td&gt;Stable &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;, outside reordered areas, plus protection for &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt; or &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 and Action Text have been fixed since March 2025&lt;/strong&gt;, contrary to what many blog posts still claim. The technique used provides a useful pattern for a custom element under morph: &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;trix-editor&amp;gt;&lt;/code&gt; sets a &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt; attribute on initialization and declares it in &lt;code class=&quot;highlighter-rouge&quot;&gt;observedAttributes&lt;/code&gt;. The server HTML does not contain it, so &lt;strong&gt;the morph removes it&lt;/strong&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;attributeChangedCallback&lt;/code&gt; fires, and the element reinitializes itself.&lt;/p&gt;

&lt;p&gt;It is a self-healing custom element. If you write your own, do that.&lt;/p&gt;

&lt;h3 id=&quot;what-leaks-if-you-do-not-clean-up&quot;&gt;What leaks if you do not clean up&lt;/h3&gt;

&lt;p&gt;With Turbo, the same document and JavaScript context can remain active for a long time. Every resource owned by a controller instance needs its teardown in &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt;: &lt;code class=&quot;highlighter-rouge&quot;&gt;setInterval&lt;/code&gt; timers, &lt;code class=&quot;highlighter-rouge&quot;&gt;addEventListener&lt;/code&gt; calls on &lt;code class=&quot;highlighter-rouge&quot;&gt;window&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;document&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;IntersectionObserver&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;ResizeObserver&lt;/code&gt; instances (which keep the observed node alive and block collection of the entire detached subtree), Action Cable subscriptions, and Chart.js instances. A genuinely shared or singleton resource follows a different lifecycle, often with reference counting; every &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; must not destroy it.&lt;/p&gt;

&lt;p&gt;WebGL contexts deserve a mention. Their quota depends on the browser, GPU and implementation. Keep leaking Mapbox maps and the browser may lose one or more contexts or refuse to create a new one; affected canvases can turn blank or remain unusable until they are reinitialized.&lt;/p&gt;

&lt;h3 id=&quot;injecting-a-stream-without-an-http-response&quot;&gt;Injecting a stream without an HTTP response&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.renderStreamMessage()&lt;/code&gt; accepts an HTML string containing &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; elements and applies them as if they had arrived over the network. The &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags inside the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt; are activated, permanent elements are preserved, focus is restored by &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is what you need for: a transport other than Action Cable (raw WebSocket, SSE, &lt;code class=&quot;highlighter-rouge&quot;&gt;postMessage&lt;/code&gt; from a service worker), an optimistic update synthesized on the client before the server confirms, or a native shell pushing HTML into the 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;network-and-offline&quot;&gt;Network and offline&lt;/h2&gt;

&lt;h3 id=&quot;when-the-request-fails-nothing-happens&quot;&gt;When the request fails, nothing happens&lt;/h3&gt;

&lt;p&gt;Turbo dispatches &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt;, which bubbles and crosses &lt;em&gt;shadow roots&lt;/em&gt;, with &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.request&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;detail.error&lt;/code&gt;. A listener placed on &lt;code class=&quot;highlighter-rouge&quot;&gt;document&lt;/code&gt; catches it reliably.&lt;/p&gt;

&lt;p&gt;What happens next depends on the context, and the difference is brutal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a frame or a form submission, the user sees nothing.&lt;/strong&gt; No banner, no toast. A &lt;code class=&quot;highlighter-rouge&quot;&gt;console.error&lt;/code&gt;, and the frame stays on its old content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a Drive visit, Turbo leaves the page.&lt;/strong&gt; The network failure is recorded as &lt;code class=&quot;highlighter-rouge&quot;&gt;SystemStatusCode.networkFailure&lt;/code&gt;, the adapter dispatches &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:reload&lt;/code&gt; with &lt;code class=&quot;highlighter-rouge&quot;&gt;reason: &quot;request_failed&quot;&lt;/code&gt;, then performs a plain navigation. Offline, that means the browser’s network error page, with the application’s entire JavaScript context gone. You lose client state just as the network has already failed.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-interne&quot;&gt;internal&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;The name &lt;code class=&quot;highlighter-rouge&quot;&gt;reload&lt;/code&gt; is misleading, and it took me a while to understand what was actually happening:&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;) This is &lt;strong&gt;not&lt;/strong&gt; a &lt;code class=&quot;highlighter-rouge&quot;&gt;location.reload()&lt;/code&gt;. It is a full-page navigation to &lt;strong&gt;the visit’s destination&lt;/strong&gt;, the one that just failed. So on an offline link click you do not land on the page you were on: you land on the network error page at the link’s address, and the previous page’s state is gone.&lt;/p&gt;

  &lt;p&gt;Two details that come with it. &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:reload&lt;/code&gt; is dispatched &lt;strong&gt;without &lt;code class=&quot;highlighter-rouge&quot;&gt;cancelable&lt;/code&gt;&lt;/strong&gt;: listening for it lets you prevent nothing. And its &lt;code class=&quot;highlighter-rouge&quot;&gt;detail&lt;/code&gt; is the whole reason object, so &lt;code class=&quot;highlighter-rouge&quot;&gt;event.detail.reason&lt;/code&gt; is &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;request_failed&quot;&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;event.detail.context.statusCode&lt;/code&gt; carries the internal code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hence the only interception point that works: &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt;, earlier in the chain. The &lt;code class=&quot;highlighter-rouge&quot;&gt;preventDefault()&lt;/code&gt; there is not cosmetic, since it makes &lt;code class=&quot;highlighter-rouge&quot;&gt;#willDelegateErrorHandling&lt;/code&gt; return &lt;code class=&quot;highlighter-rouge&quot;&gt;false&lt;/code&gt;, which short-circuits &lt;code class=&quot;highlighter-rouge&quot;&gt;requestErrored&lt;/code&gt;, therefore &lt;code class=&quot;highlighter-rouge&quot;&gt;recordResponse&lt;/code&gt;, so the adapter is never asked to do anything and &lt;strong&gt;the navigation does not happen&lt;/strong&gt;. In exchange, &lt;code class=&quot;highlighter-rouge&quot;&gt;FetchRequest#perform&lt;/code&gt; still rethrows the error: expect an unhandled promise rejection in the 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;// also prevents the full reload on a Drive visit&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;Watch out for an ordering trap: &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-response&lt;/code&gt; fires &lt;strong&gt;only if there is a response&lt;/strong&gt;. Network-loss detection built on it will never fire when the network is actually down.&lt;/p&gt;

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

&lt;p&gt;Turbo navigations are &lt;code class=&quot;highlighter-rouge&quot;&gt;window.fetch()&lt;/code&gt; calls. Turbo passes no &lt;code class=&quot;highlighter-rouge&quot;&gt;mode&lt;/code&gt; option, so on the service worker side the request has &lt;code class=&quot;highlighter-rouge&quot;&gt;mode === &quot;cors&quot;&lt;/code&gt; and, more surprisingly, &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;destination === &quot;&quot;&lt;/code&gt;&lt;/strong&gt;, the default value for anything coming out of &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A service worker that routes on &lt;code class=&quot;highlighter-rouge&quot;&gt;request.mode === &#39;navigate&#39;&lt;/code&gt; therefore misses every Drive navigation. And the fix you read everywhere, adding &lt;code class=&quot;highlighter-rouge&quot;&gt;|| request.destination === &#39;document&#39;&lt;/code&gt;, catches nothing at all: that &lt;code class=&quot;highlighter-rouge&quot;&gt;destination&lt;/code&gt; only exists for navigations initiated by the browser itself. You have to discriminate on what Turbo actually sends:&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;// browser navigation&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;// Turbo Drive&#39;s fetch()&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;// not a frame request&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;// not a stream request&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;Both exclusions are essential: Frames and Streams also use &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch()&lt;/code&gt;, carry &lt;code class=&quot;highlighter-rouge&quot;&gt;destination === &quot;&quot;&lt;/code&gt; and accept HTML. Without them, a partial response can pollute the cache for the same URL that Drive later visits. The second route works as is: those requests really are initiated by the browser and carry a populated &lt;code class=&quot;highlighter-rouge&quot;&gt;destination&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This matcher also assumes that your other application &lt;code class=&quot;highlighter-rouge&quot;&gt;fetch()&lt;/code&gt; calls do not request HTML over GET without a distinguishing header. If they do, the HTTP metadata above cannot identify Drive on its own: mark Drive requests with an application header from &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt; and make the predicate require it.&lt;/p&gt;

&lt;p&gt;Two service worker failures come up often with Turbo: a wrong &lt;code class=&quot;highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt; and inconsistent asset fingerprints. They are not the only possible failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serving a response with the wrong &lt;code class=&quot;highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt;.&lt;/strong&gt; On a Drive visit, Turbo records &lt;code class=&quot;highlighter-rouge&quot;&gt;contentTypeMismatch&lt;/code&gt;, dispatches &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:reload&lt;/code&gt;, then performs a full navigation to the destination. In a frame, a non-HTML response leaves the frame unchanged after the fetch events. Only that second case is silent in the interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serving HTML whose asset fingerprints no longer match.&lt;/strong&gt; Turbo compares the &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-track=&quot;reload&quot;&lt;/code&gt; elements between snapshots. On divergence, it triggers a full browser reload. A loop only appears if subsequent responses keep alternating between incompatible HTML and assets; one stale cache entry does not guarantee a loop on its own.&lt;/p&gt;

&lt;p&gt;Rails 8 adds constraints of its own:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Put the service worker at a stable root URL.&lt;/strong&gt; By default, its scope cannot reach above the directory of its URL. The &lt;code class=&quot;highlighter-rouge&quot;&gt;Service-Worker-Allowed&lt;/code&gt; HTTP header can widen that scope, but it does not fix the unstable URL of a Propshaft-digested asset. Two simple options are &lt;code class=&quot;highlighter-rouge&quot;&gt;public/service-worker.js&lt;/code&gt;, or the route Rails 8 generates, which is &lt;strong&gt;commented out by default&lt;/strong&gt; in &lt;code class=&quot;highlighter-rouge&quot;&gt;config/routes.rb&lt;/code&gt; and therefore has to be uncommented: &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;. Note the &lt;code class=&quot;highlighter-rouge&quot;&gt;rails/pwa&lt;/code&gt; controller and the path without &lt;code class=&quot;highlighter-rouge&quot;&gt;.js&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Importmaps do not apply inside a service worker.&lt;/strong&gt; There is no &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script type=&quot;importmap&quot;&amp;gt;&lt;/code&gt; in there. Use &lt;code class=&quot;highlighter-rouge&quot;&gt;importScripts()&lt;/code&gt; in a classic worker, or register a module worker with &lt;code class=&quot;highlighter-rouge&quot;&gt;{ type: &quot;module&quot; }&lt;/code&gt; and static imports resolved as real module URLs, without an importmap.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Digested asset URLs change with their content.&lt;/strong&gt; A precache list written by hand goes stale on the first deploy that touches a file. Prefer runtime caching, by &lt;code class=&quot;highlighter-rouge&quot;&gt;request.destination&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An official patch is in progress (&lt;a href=&quot;https://github.com/hotwired/turbo/pull/1427&quot;&gt;turbo#1427&lt;/a&gt;, a &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.offline&lt;/code&gt; API shipped in a separate bundle) but it is &lt;strong&gt;open, not merged&lt;/strong&gt;. Do not write an architecture that depends on it.&lt;/p&gt;

&lt;h3 id=&quot;action-cable-replays-nothing&quot;&gt;Action Cable replays nothing&lt;/h3&gt;

&lt;p&gt;This production risk receives little attention in Hotwire documentation.&lt;/p&gt;

&lt;p&gt;Action Cable is publish/subscribe with no persistence, no acknowledgement, no sequence number and no history. A message published while a consumer is disconnected is delivered to those who are subscribed at that instant, then thrown away. There is nothing in the protocol capable of replaying it.&lt;/p&gt;

&lt;p&gt;Action Cable reconnects on its own, and the tab receives the &lt;strong&gt;subsequent&lt;/strong&gt; messages again. Everything that went through during the outage is &lt;strong&gt;permanently lost&lt;/strong&gt;. The page stays stale until a navigation, an explicit catch-up refresh, or a later payload resynchronizes it. There is no automatic replay or error indication.&lt;/p&gt;

&lt;p&gt;Wifi switching over, a computer waking up, a tunnel, a deploy: this is not an edge case, it is the daily life of a mobile user.&lt;/p&gt;

&lt;p&gt;The public DOM observation surface fits in one attribute: &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-cable-stream-source&amp;gt;&lt;/code&gt; sets and removes &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt;. You can instrument subscription callbacks or the Action Cable consumer, but that couples the application to their internals.&lt;/p&gt;

&lt;blockquote class=&quot;callout callout-capot&quot;&gt;
  &lt;p&gt;&lt;strong class=&quot;callout-label&quot;&gt;Under the hood&lt;/strong&gt; &lt;span class=&quot;tag tag-observe&quot;&gt;Verified&lt;/span&gt;&lt;/p&gt;

  &lt;p&gt;The element does dispatch an event, just not the one you would want: a &lt;code class=&quot;highlighter-rouge&quot;&gt;MessageEvent(&quot;message&quot;)&lt;/code&gt; &lt;strong&gt;per received payload&lt;/strong&gt;, which is how &lt;code class=&quot;highlighter-rouge&quot;&gt;StreamObserver&lt;/code&gt; consumes it (&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;). What it does not dispatch is any &lt;strong&gt;lifecycle&lt;/strong&gt; event at all: &lt;code class=&quot;highlighter-rouge&quot;&gt;subscriptionConnected&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;subscriptionDisconnected&lt;/code&gt; only set and remove the attribute.&lt;/p&gt;

  &lt;p&gt;To stay on the custom element’s public interface, observe that attribute with a &lt;code class=&quot;highlighter-rouge&quot;&gt;MutationObserver&lt;/code&gt; or, for a simple banner, 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;There is one morphing trap to remove before observing it. &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt; is runtime state and is absent from the server HTML, so idiomorph will otherwise remove the attribute while the subscription is still alive. The custom element does not observe &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt;, so it will not put it back until a real reconnect. Preserve that one attribute during morphs:&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;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;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;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;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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Hence the workaround, which is entirely application 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;c1&quot;&gt;// Stimulus controller placed on &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;Handle &lt;code class=&quot;highlighter-rouge&quot;&gt;visibilitychange&lt;/code&gt; (the machine waking up) and &lt;code class=&quot;highlighter-rouge&quot;&gt;window.online&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;With &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_refreshes_with method: :morph, scroll: :preserve&lt;/code&gt;, this catch-up costs one round trip and preserves the scroll. Focus survives when its node stays in place; idiomorph can also restore it on an identified &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;input&amp;gt;&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;. &lt;strong&gt;This is what makes pairing morphing with broadcasts practical&lt;/strong&gt;: the catch-up becomes cheap enough that you actually write it.&lt;/p&gt;

&lt;p&gt;One last thing to know in the same vein: &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; also drops a refresh that arrives while a navigation is already in flight (&lt;code class=&quot;highlighter-rouge&quot;&gt;!this.navigator.currentVisit&lt;/code&gt;), with no retry. It is a second, narrower path to the same staleness.&lt;/p&gt;

&lt;h2 id=&quot;debugging-checklist&quot;&gt;Debugging checklist&lt;/h2&gt;

&lt;p&gt;This checklist has resolved nearly every Turbo bug I have encountered. It is deliberately mechanical: each step eliminates a class of causes, and the first three take ten seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The console, before the network tab.&lt;/strong&gt; A &lt;code class=&quot;highlighter-rouge&quot;&gt;console.error&lt;/code&gt; changes the diagnosis entirely. &lt;code class=&quot;highlighter-rouge&quot;&gt;Form responses must redirect&lt;/code&gt; → HTTP status. &lt;code class=&quot;highlighter-rouge&quot;&gt;unknown action&lt;/code&gt; or &lt;code class=&quot;highlighter-rouge&quot;&gt;target or targets attribute is missing&lt;/code&gt; → the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; is malformed. &lt;code class=&quot;highlighter-rouge&quot;&gt;Content missing&lt;/code&gt; → frame identifier. &lt;strong&gt;An empty console and nothing on screen:&lt;/strong&gt; look first for a silent no-op, a missing target, or a cancelled event. It narrows the diagnosis, but does not prove that the response was empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Did the request leave, and with which headers.&lt;/strong&gt; In the network tab, inspect &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; and &lt;code class=&quot;highlighter-rouge&quot;&gt;X-Turbo-Request-Id&lt;/code&gt;. Without &lt;code class=&quot;highlighter-rouge&quot;&gt;text/vnd.turbo-stream.html&lt;/code&gt; in &lt;code class=&quot;highlighter-rouge&quot;&gt;Accept&lt;/code&gt;, the usual content negotiation will not select that format, but a &lt;code class=&quot;highlighter-rouge&quot;&gt;.turbo_stream&lt;/code&gt; extension or an explicit &lt;code class=&quot;highlighter-rouge&quot;&gt;params[:format]&lt;/code&gt; still can. Inspect the URL and params too. If the request never left at all, look for a &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo=&quot;false&quot;&lt;/code&gt; on an ancestor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The status, and only at this point.&lt;/strong&gt; A 200 with no redirect on a POST is the classic. A &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-stream&lt;/code&gt; Content-Type selects the stream renderer before page/frame rendering, but the status still feeds &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:submit-end.detail.success&lt;/code&gt;. For frame navigation started by a link or &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;, both 2xx and 4xx/5xx HTML go through &lt;code class=&quot;highlighter-rouge&quot;&gt;loadResponse()&lt;/code&gt;. For an ordinary HTML frame-form response, status instead selects the success/failure path, the frame that receives the response, and whether the Drive cache is cleared.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Does the target actually exist, right now.&lt;/strong&gt; In the console, &lt;code class=&quot;highlighter-rouge&quot;&gt;document.getElementById(&quot;your_id&quot;)&lt;/code&gt;. Also check it is not inside a &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt;, inside an &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;, or inside a &lt;code class=&quot;highlighter-rouge&quot;&gt;loading=&quot;lazy&quot;&lt;/code&gt; frame that has not loaded: three places Turbo will not look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. How many of them are there?&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;document.querySelectorAll(&quot;#your_id&quot;).length&lt;/code&gt;. The answer must be &lt;code class=&quot;highlighter-rouge&quot;&gt;1&lt;/code&gt;. A &lt;code class=&quot;highlighter-rouge&quot;&gt;2&lt;/code&gt; explains both streams hitting the wrong element and morphs behaving strangely at a distance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Which renderer ran.&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; needs an effective &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt; method and a page refresh in &lt;code class=&quot;highlighter-rouge&quot;&gt;PageView&lt;/code&gt;’s sense. When there is a Visit, that means &lt;code class=&quot;highlighter-rouge&quot;&gt;action: &quot;replace&quot;&lt;/code&gt; and the same &lt;code class=&quot;highlighter-rouge&quot;&gt;pathname&lt;/code&gt; as the last rendered page; with no Visit, &lt;code class=&quot;highlighter-rouge&quot;&gt;isPageRefresh()&lt;/code&gt; returns &lt;code class=&quot;highlighter-rouge&quot;&gt;true&lt;/code&gt; directly. A successful full-page submission creates a Visit, while a failed response takes the direct rendering path. A missing &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= yield :head %&amp;gt;&lt;/code&gt; drops the &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt; meta tag, but it is only one possible cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Is it coming from a broadcast.&lt;/strong&gt; Check whether the HTML received is meant to be identical for everyone. With standard Devise, a broadcast partial that calls &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; fails with &lt;code class=&quot;highlighter-rouge&quot;&gt;Devise::MissingWarden&lt;/code&gt;; with a home-grown helper it may return &lt;code class=&quot;highlighter-rouge&quot;&gt;nil&lt;/code&gt;, but the partial still cannot safely personalize one shared payload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Run the same test in Safari, or on an iPhone.&lt;/strong&gt; A difference limited to those browsers is a reason to check &lt;code class=&quot;highlighter-rouge&quot;&gt;Element.prototype.moveBefore&lt;/code&gt; and idiomorph’s &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt; fallback. It is not proof: application code, CSS and other API differences remain suspects. Reduce the case before clearing the application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Cut the network, and watch what happens.&lt;/strong&gt; It is the only way to discover that an offline link click leaves your application, and that nobody is listening to &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-symptom-index&quot;&gt;The symptom index&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Symptom&lt;/th&gt;
      &lt;th&gt;Most likely cause&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;The form goes out, nothing moves, console: “Form responses must redirect”&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;200&lt;/code&gt; with HTML on a POST. Answer &lt;code class=&quot;highlighter-rouge&quot;&gt;422&lt;/code&gt; on failure, &lt;code class=&quot;highlighter-rouge&quot;&gt;303&lt;/code&gt; on success&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The form updates an unexpected region&lt;/td&gt;
      &lt;td&gt;It inherits the frame that contains it, that frame’s &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt;, or a &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-frame&lt;/code&gt; on the form or submit button&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The frame empties out, “Content missing”&lt;/td&gt;
      &lt;td&gt;The response has no &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; with the same id. Often a redirect to &lt;code class=&quot;highlighter-rouge&quot;&gt;/login&lt;/code&gt;. Mark the target page with &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;The stream arrives, visible in the network tab, no effect&lt;/td&gt;
      &lt;td&gt;The target does not exist in the DOM. It is a &lt;strong&gt;completely silent&lt;/strong&gt; no-op&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The stream does not arrive on a GET link&lt;/td&gt;
      &lt;td&gt;Add &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-stream&lt;/code&gt;: without it the &lt;code class=&quot;highlighter-rouge&quot;&gt;Accept&lt;/code&gt; header is not sent&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;A &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; built by hand without its &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;The &lt;code class=&quot;highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt; is wrong&lt;/td&gt;
      &lt;td&gt;A frame stays unchanged after the fetch events; a Drive visit dispatches &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:reload&lt;/code&gt; and then navigates fully to the destination&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The JS stops working after a navigation&lt;/td&gt;
      &lt;td&gt;Initialization on &lt;code class=&quot;highlighter-rouge&quot;&gt;DOMContentLoaded&lt;/code&gt;, which only fires on the first load. Move to &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:load&lt;/code&gt; or to Stimulus&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The JS stops &lt;strong&gt;only since morphing was turned on&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Inline &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags do not re-execute. &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; is also path-dependent: absent after a successful unsafe form, and absent on &lt;code class=&quot;highlighter-rouge&quot;&gt;Session#refresh&lt;/code&gt; unless a previewable snapshot of the current URL is already cached and the current document is cacheable&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Morphing “does not work”, the page gets replaced&lt;/td&gt;
      &lt;td&gt;On a page refresh, the effective method is not &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt;. Check the meta tag emitted by &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_refreshes_with&lt;/code&gt;, hence &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= yield :head %&amp;gt;&lt;/code&gt;, or the &lt;code class=&quot;highlighter-rouge&quot;&gt;method&lt;/code&gt; attribute on the &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; stream&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Morphing does not trigger after a form&lt;/td&gt;
      &lt;td&gt;For a successful full-page response turned into a Visit, the effective method must be &lt;code class=&quot;highlighter-rouge&quot;&gt;morph&lt;/code&gt;, the action &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt;, and the &lt;code class=&quot;highlighter-rouge&quot;&gt;pathname&lt;/code&gt; unchanged. Without an explicit action, Turbo chooses &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; only for a redirect to the full departure URL; otherwise it chooses &lt;code class=&quot;highlighter-rouge&quot;&gt;advance&lt;/code&gt;. A full-page 4xx goes straight through &lt;code class=&quot;highlighter-rouge&quot;&gt;renderPage&lt;/code&gt;, a 5xx through &lt;code class=&quot;highlighter-rouge&quot;&gt;ErrorRenderer&lt;/code&gt;, and a frame submission through its frame renderer: those branches do not apply this Visit test&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Full browser reload on every navigation&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-track=&quot;reload&quot;&lt;/code&gt; signature mismatch. Normal after a deploy. A loop requires successive responses to remain inconsistent; look for a dynamically injected asset or a service worker cache mixing versions&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Lists animate all over the place under morph&lt;/td&gt;
      &lt;td&gt;No stable &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; on the items, or an &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; duplicated in either root of this morph, or the tag changed&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Works on your machine, breaks on an iPhone, under morph&lt;/td&gt;
      &lt;td&gt;Check for &lt;code class=&quot;highlighter-rouge&quot;&gt;Element.prototype.moveBefore&lt;/code&gt;. The &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt; fallback can disconnect and reconnect moved nodes; WebKit browsers often share that path, but this symptom alone proves neither the cause nor the innocence of application code&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The text the user is typing gets erased&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;syncInputValue&lt;/code&gt; writes the live properties, and Turbo does not enable &lt;code class=&quot;highlighter-rouge&quot;&gt;ignoreActiveValue&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The back button shows stale content&lt;/td&gt;
      &lt;td&gt;With a usable restoration snapshot, that snapshot is the only render and Turbo performs no fetch. Without one, it goes back to the network. Use &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-cache-control: no-cache&lt;/code&gt; if the content is sensitive&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The back button shows a duplicated widget&lt;/td&gt;
      &lt;td&gt;The snapshot kept the DOM injected by the widget. Remove it in &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt;, the deterministic hook intended for that job. A synchronous &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; may also precede the deferred clone, but should not be your only cache contract&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Flash messages do not appear under morph&lt;/td&gt;
      &lt;td&gt;A refresh broadcast carries no flash. And if the flash has the same &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; and the same text, the morph changes nothing at all&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;A broadcast updates the wrong user’s page&lt;/td&gt;
      &lt;td&gt;Stream name not partitioned, or a channel with no authorization check. Signing is not authorizing&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;A broadcast refreshes its author’s tab too&lt;/td&gt;
      &lt;td&gt;Missing or nil &lt;code class=&quot;highlighter-rouge&quot;&gt;request_id&lt;/code&gt;, a broadcast outside the request, or an identifier evicted after twenty new Turbo requests&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Real-time updates stop, with no error&lt;/td&gt;
      &lt;td&gt;The WebSocket dropped. Action Cable replays nothing. Catch up on the &lt;code class=&quot;highlighter-rouge&quot;&gt;connected&lt;/code&gt; attribute after protecting it from morphs&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Nothing happens when the network is down, in a frame or a form&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt; is dispatched but nobody is listening&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The browser error page appears when the network is down, on a link click&lt;/td&gt;
      &lt;td&gt;A Drive visit failing on the network navigates to the destination, outside Turbo. &lt;code class=&quot;highlighter-rouge&quot;&gt;preventDefault()&lt;/code&gt; on &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt; is the only way to stop it: &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:reload&lt;/code&gt; is not cancelable&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;After a form returns 404, the frame shows the error but &lt;code class=&quot;highlighter-rouge&quot;&gt;reload()&lt;/code&gt; returns to the old content&lt;/td&gt;
      &lt;td&gt;A failed form response does not replace an existing &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;. Navigation started by a link or by &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt; sets that URL before the request, so &lt;code class=&quot;highlighter-rouge&quot;&gt;reload()&lt;/code&gt; then repeats the failing URL instead&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;After a 500 on a link click, the application goes unstable&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;ErrorRenderer&lt;/code&gt; replaces the whole &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, re-activates scripts unless they have &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-eval=&quot;false&quot;&lt;/code&gt;, and does not run Bardo: &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; is not honoured&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Back navigation got slow since a search form was added&lt;/td&gt;
      &lt;td&gt;A failed GET with an HTML response clears the full-page cache; inside a frame, every failed form submission clears it&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Some changes are not broadcast from a &lt;code class=&quot;highlighter-rouge&quot;&gt;rails runner&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;The process exited before the debounce task scheduled 0.5 s out. If it stays alive beyond the delay, the broadcast is sent&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;what-changed-recently&quot;&gt;What changed recently&lt;/h2&gt;

&lt;p&gt;If you are rereading documentation or blog posts written before 2026, be wary of these points, all changed in version 8.0.21 of January 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; was &lt;strong&gt;removed&lt;/strong&gt;. It is &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; was &lt;strong&gt;removed&lt;/strong&gt;. It is &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; was &lt;strong&gt;added&lt;/strong&gt;.&lt;/li&gt;
  &lt;li&gt;The &lt;code class=&quot;highlighter-rouge&quot;&gt;method&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;scroll&lt;/code&gt; attributes on the &lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; stream action were added: they override the meta tags, broadcast by broadcast.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;before&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;after&lt;/code&gt; now deduplicate siblings, as &lt;code class=&quot;highlighter-rouge&quot;&gt;append&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;prepend&lt;/code&gt; already did for children.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And a few things present in the code but absent from the official reference, all of which belong under the &lt;span class=&quot;tag tag-observe&quot;&gt;Verified&lt;/span&gt; label: the &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-frame-morph&lt;/code&gt; event, the &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; and &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo.config.drive.unvisitableExtensions&lt;/code&gt; options, reassigning &lt;code class=&quot;highlighter-rouge&quot;&gt;event.detail.url&lt;/code&gt; on &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-fetch-request&lt;/code&gt;, and the fact that &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-render&lt;/code&gt; is declared cancelable even though cancelling it produces no effect.&lt;/p&gt;

&lt;p&gt;The internal details here are linked to source or tests from the pinned versions; revisit those links whenever you upgrade Turbo.&lt;/p&gt;

&lt;p&gt;The useful rule is still the one at the top: choose the replacement scope first, then decide who names its target. Use Drive while the whole page is the right boundary, a Frame when the client names one region backed by a URL, and Streams when the server must name one or more targets. Morphing only changes how that choice is applied.&lt;/p&gt;

&lt;p&gt;If your Hotwire application has accumulated these symptoms, this is the work I do at &lt;a href=&quot;/en/contact/?ref=turbo-reference&quot;&gt;SXN Labs&lt;/a&gt;. I start from a reproducible case, fix the broken contract and verify the result in 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">Choose between Drive, Frames and Streams by scope, then debug caching, morphing and broadcasts with this Turbo 8.0.23 reference.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-08-06-turbo-drive-frames-streams-reference.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">Six parallel workstreams, one integration bill</title>
    <link href="https://sxnlabs.com/en/opinion/2026/08/05/parallelisme-la-facture-arrive-a-l-integration/" rel="alternate" type="text/html" title="Six parallel workstreams, one integration bill" />
    <published>2026-08-05T09:00:00+02:00</published>
    <updated>2026-08-05T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/opinion/2026/08/05/parallelisme-la-facture-arrive-a-l-integration/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/opinion/2026/08/05/parallelisme-la-facture-arrive-a-l-integration/">&lt;p&gt;Six feature workstreams passed their tests and reviews, then produced ten serious defects when they came together. They had been designed as independent even though they changed the same quotes, job sites, and contacts, enforced the same business rules, and rewrote some of the same files.&lt;/p&gt;

&lt;p&gt;The application schedules field visits, sends quotes for signature, and notifies clients by text message. Parallel work shortened each workstream’s development time while the effort required to produce a coherent product remained.&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.en.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.en.svg?v=20260827-layering&quot; alt=&quot;Six workstreams that passed separately converge in pairs on the same objects, business rules, and artifacts, then on an integration that reveals ten serious defects.&quot; /&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;The tickets separated the work, but not the systems being changed.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;two-correct-workstreams-one-inconsistent-flow&quot;&gt;Two correct workstreams, one inconsistent flow&lt;/h2&gt;

&lt;p&gt;One workstream added online quote acceptance: the system checked the quote, confirmed the order, and generated the invoice. Another let the back office change the job site or delete the associated request. Each behavior made sense in isolation and had test coverage.&lt;/p&gt;

&lt;p&gt;Together, they opened a gap between validation and confirmation because the back office could change the job site while the client was signing. The client then approved the current quote while the signature provider still held a contract describing the previous version.&lt;/p&gt;

&lt;p&gt;No isolated test covered that sequence. The defect lived in the seam, around shared state that could change before the irreversible action.&lt;/p&gt;

&lt;h2 id=&quot;the-tickets-hid-a-shared-system&quot;&gt;The tickets hid a shared system&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;A duplicated business rule.&lt;/strong&gt; Three paths needed to preserve a quote’s recipient, using three definitions: a full contact fingerprint, only its ID, or no check.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;State read too early.&lt;/strong&gt; Two requests could load the same draft and generate separate PDFs, with no reliable way to determine which one represented the accepted offer.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;A shared artifact.&lt;/strong&gt; Two branches rewrote the database schema. The merge removed a table required for appointments even though each branch was correct in isolation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;faster-implementation-moves-the-bottleneck&quot;&gt;Faster implementation moves the bottleneck&lt;/h2&gt;

&lt;p&gt;When every workstream can interact with every other one, the maximum number of pairs to examine follows &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;Parallel workstreams&lt;/th&gt;
      &lt;th&gt;Calculation&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Possible pairs&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;These 15 or 45 pairs do not imply as many conflicts. They are an upper bound; actual integration cost depends on the density of the dependency graph and the cost of each seam.&lt;/p&gt;

&lt;p&gt;AI lets one developer start several workstreams cheaply. Generation, local tests, and review parallelize well. Reconciling two interpretations of a business rule remains sequential work that requires an understanding of the whole product.&lt;/p&gt;

&lt;h2 id=&quot;before-starting-parallel-work&quot;&gt;Before starting parallel work&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.en.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.en.svg&quot; alt=&quot;The back office changes the job site that the signature flow reads. This dependency requires coordination and an end-to-end test; the PRs are stacked only when the second depends on code from the first.&quot; /&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;The read/write overlap requires coordination, not necessarily a stack. The second PR branches from the first only when it depends on its code or schema.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Map reads and writes.&lt;/strong&gt; List the objects each workstream reads or changes, along with shared invariants and artifacts.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Classify dependencies.&lt;/strong&gt; If one workstream writes data that another reads or writes, or if they share an invariant or artifact, plan their integration together.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Stack code dependencies.&lt;/strong&gt; If a PR uses code or schema from the previous PR, base it on the previous PR’s branch so its diff remains focused. Merge the stack from the bottom upward and restack its descendants after each change. A business dependency alone needs coordinated integration and an end-to-end test, not necessarily a stack.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Close race conditions.&lt;/strong&gt; Bind the signature to an immutable version of the quote and job site, then compare that version in the same transaction that confirms the order. If it differs, invalidate the signature and regenerate the contract.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Test after every merge.&lt;/strong&gt; Regenerate shared artifacts, then rerun the affected business flows. A green suite on each PR does not cover their seams.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Schedule integration.&lt;/strong&gt; Name the integrator, set the cadence, and reserve the required time. The delivery date must include this work.&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="Method" />
    <category term="Project" />
    <category term="Integration" />
    <category term="AI" />
    <summary type="html">Six workstreams that passed in isolation created ten integration defects. The cost of parallel work depends on the rules, state, and artifacts they share.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/posts/parallelisme-integration/social-preview.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">Contrast cannot be checked in the mockup alone</title>
    <link href="https://sxnlabs.com/en/web/2026/07/30/accessibilite-ne-se-verifie-pas-dans-la-maquette/" rel="alternate" type="text/html" title="Contrast cannot be checked in the mockup alone" />
    <published>2026-07-30T09:00:00+02:00</published>
    <updated>2026-07-30T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/web/2026/07/30/accessibilite-ne-se-verifie-pas-dans-la-maquette/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/web/2026/07/30/accessibilite-ne-se-verifie-pas-dans-la-maquette/">&lt;p&gt;A brand’s design can look perfect on paper and still produce screens that some of your users simply cannot read. It happened to me recently, on an interface I develop: hundreds of texts too pale against their background, including the main button label and the tab bar, across some thirty screens. No design review had caught it.&lt;/p&gt;

&lt;p&gt;This article covers only one part of accessibility: the contrast of text and indicators. It is visible, measurable, and still surprisingly easy to miss.&lt;/p&gt;

&lt;figure&gt;
  &lt;img src=&quot;/images/posts/accessibilite-maquette/maquette-vs-realite.en.svg&quot; alt=&quot;The same screen shown twice: on the left in the mockup, crisp and readable; on the right in bright sunlight, washed out, the main button and the tab bar barely visible&quot; /&gt;
  &lt;figcaption&gt;On the left, the screen the review signs off. On the right, the same screen outside, the one the user actually gets.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;what-the-mockup-does-not-show&quot;&gt;What the mockup does not show&lt;/h2&gt;

&lt;p&gt;A review looks at intent, on a nice screen, in good conditions. Real life also means the same screen in bright sunlight, on a cheap phone, or read by someone who is sixty. Light grey text on a coloured background can look “sleek” in the design file and become invisible outside.&lt;/p&gt;

&lt;p&gt;In this interface, much of the text sits on a gradient, a translucent layer or a blend of tints. The colour to judge is the one obtained once those layers are composed, in every theme and every state, not only the value written in the file. The mockup can and should eliminate some mistakes. On its own, it cannot prove that the final implementation remains readable everywhere.&lt;/p&gt;

&lt;figure&gt;
  &lt;img src=&quot;/images/posts/accessibilite-maquette/couleur-declaree-vs-vue.en.svg&quot; alt=&quot;On the left, a flat orange fill carrying the colour declared in the code; on the right, the same white label on the gradient actually painted, whose light end drops to 1.5:1&quot; /&gt;
  &lt;figcaption&gt;The colour written in the file is a flat fill. What gets painted is a gradient, and the white text drops to 1.5:1 on it, where AA asks for 4.5.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h2 id=&quot;what-a-green-score-does-not-prove&quot;&gt;What a green score does not prove&lt;/h2&gt;

&lt;p&gt;The next reflex is to run Lighthouse, get a nice green score and tick the box. But a green score does not prove that every piece of text is readable. It means the automated checks found no problem. Gradients, background images and some overlapping layers remain difficult to analyse: they need a dedicated check.&lt;/p&gt;

&lt;p&gt;The most instructive trap came from our own check. To judge a gradient, it computed the average colour. A plate going from dark to light therefore produced a reassuring average, a colour painted nowhere on screen. The test went green on a fiction.&lt;/p&gt;

&lt;figure&gt;
  &lt;img src=&quot;/images/posts/accessibilite-maquette/moyenne-vs-pire-point.en.svg&quot; alt=&quot;A gradient from dark violet to light coral: the average of the two ends reaches 4.51:1 and passes the test, while the lightest spot drops to 3.24:1 and fails&quot; /&gt;
  &lt;figcaption&gt;The same gradient judged two ways. On the average, the check goes green. On the worst spot, it fails.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;A check that shows green on a colour that does not exist sells peace of mind while guaranteeing nothing. We fixed it to judge the worst spot of the gradient, not its average.&lt;/p&gt;

&lt;h2 id=&quot;why-it-was-a-product-problem&quot;&gt;Why it was a product problem&lt;/h2&gt;

&lt;p&gt;The worst cases landed on the button everyone taps, the navigation bar, and the little frame that shows where you are when navigating by keyboard. The elements everyone touches, barely visible to a chunk of people.&lt;/p&gt;

&lt;p&gt;Text that is too pale is not “a bit less pretty”. It is a user who cannot find the button, who gives up, who calls support. On a consumer product, readability is a conversion number before it is a compliance checkbox.&lt;/p&gt;

&lt;h2 id=&quot;what-we-fixed-and-what-remains&quot;&gt;What we fixed, and what remains&lt;/h2&gt;

&lt;p&gt;We combined the three levels that were missing: design review, automated checks and inspection of the interface actually rendered in a browser. None is enough on its own. Together, they uncover obvious mistakes, implementation mistakes and the cases automation cannot decide.&lt;/p&gt;

&lt;p&gt;The point that matters to a decision-maker is that no brand colour moved. What changed is the ink on top. Each type of background now has its own text colour, chosen once in the design system instead of eyeballed screen by screen. We went from hundreds of insufficient cases to a handful.&lt;/p&gt;

&lt;p&gt;That handful consists of white text on the brand gradient, where no single text colour meets the threshold across the whole surface. Knowing about them does not make them compliant. These residual issues still need fixing by changing the composition, for example with a local background or different placement, without necessarily touching the palette.&lt;/p&gt;

&lt;h2 id=&quot;a-guardrail-not-an-audit&quot;&gt;A guardrail, not an audit&lt;/h2&gt;

&lt;p&gt;The guardrail is a small test that runs in seconds on every change. It does not replace an accessibility audit, and does not even cover all of accessibility. It does one narrower, useful job. It stops a new colour, a rebuilt component or an added screen from slipping back below the bar without anyone noticing.&lt;/p&gt;

&lt;h2 id=&quot;the-useful-question-is-not-what-is-our-score&quot;&gt;The useful question is not “what is our score?”&lt;/h2&gt;

&lt;p&gt;Accessibility is not a favour done for a minority. It makes the product usable by people who age, who read in the sun or who simply have average eyesight: everyone, sooner or later.&lt;/p&gt;

&lt;p&gt;Since June 2025, the &lt;a href=&quot;https://commission.europa.eu/strategy-and-policy/policies/justice-and-fundamental-rights/disability/european-accessibility-act-eaa_en&quot;&gt;European Accessibility Act&lt;/a&gt; has also applied accessibility requirements to defined categories of products and services, including e-commerce, banking, transport and electronic communications. But compliance is not the best reason to care, and this contrast test is obviously not enough to establish it.&lt;/p&gt;

&lt;p&gt;For a decision-maker, the useful questions are simpler: how many of our users can actually read our screens? What stops us from becoming unreadable again next month? If the answer is “the team’s vigilance”, there is no answer.&lt;/p&gt;

&lt;p&gt;In this case, the first diagnostic and the guardrail took half a day. If you have a consumer product and nobody has ever checked the contrast of the interface actually shipped, we can look at it together.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="web" />
    <category term="Accessibility" />
    <category term="Design system" />
    <category term="Testing" />
    <category term="Front-end" />
    <summary type="html">A green test, a colour painted nowhere and thirty screens of faint text: how to check real contrast and prevent regressions.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/posts/accessibilite-maquette/social-preview.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">When AI becomes your single point of failure</title>
    <link href="https://sxnlabs.com/en/ai/2026/07/27/ia-point-de-defaillance-unique/" rel="alternate" type="text/html" title="When AI becomes your single point of failure" />
    <published>2026-07-27T09:00:00+02:00</published>
    <updated>2026-07-27T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/ai/2026/07/27/ia-point-de-defaillance-unique/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/ai/2026/07/27/ia-point-de-defaillance-unique/">&lt;p&gt;A few days ago, my monitoring dashboard sat frozen for three days. It aggregates the application errors from my projects every hour. For three days, the tile displayed perfectly plausible numbers. They were three days old.&lt;/p&gt;

&lt;p&gt;The monitoring service itself had never stopped answering. The data was there, available, one API call away. What had failed was the piece I had slipped into the middle of the pipe: an AI session.&lt;/p&gt;

&lt;h2 id=&quot;a-silent-failure-the-worst-kind&quot;&gt;A silent failure, the worst kind&lt;/h2&gt;

&lt;p&gt;The unpleasant part of the story is that the outage did not look like one.&lt;/p&gt;

&lt;p&gt;An agent that crashes shows an error, raises an alert, gets noticed. Here, the hourly agent failed cleanly, wrote nothing, and the page kept serving the last known state with its usual formatting. Nothing blinked. I found out while looking for something else.&lt;/p&gt;

&lt;p&gt;The cause was mundane. The authentication on the command line tool carrying the session had expired. No quota exceeded, no model unavailable, no bad answer. Just a stale token on a component I had never counted as part of my data collection chain.&lt;/p&gt;

&lt;h2 id=&quot;why-the-ai-was-there-in-the-first-place&quot;&gt;Why the AI was there in the first place&lt;/h2&gt;

&lt;p&gt;Convenience, and that is the interesting part.&lt;/p&gt;

&lt;p&gt;Access to the monitoring service already existed as a tool wired into my assistant. Writing a prompt along the lines of “fetch the unresolved errors, classify them, write the summary” takes ten minutes and works on the first try. Writing the equivalent API client, handling pagination, fields and edge cases, takes half a day and produces no impressive demo.&lt;/p&gt;

&lt;p&gt;So the decision was rational the moment I made it. It was much less so a month later, when that shortcut had become a production link carrying the only view I had of my applications’ health.&lt;/p&gt;

&lt;p&gt;It is a pattern I see a lot right now in companies “adding AI” to their processes. The prototype is spectacular, shipping it is painless, and nobody circles back to ask what happens the day the newest and least deterministic component in the chain stops answering.&lt;/p&gt;

&lt;h2 id=&quot;the-fix-two-hundred-boring-lines&quot;&gt;The fix, two hundred boring lines&lt;/h2&gt;

&lt;p&gt;The replacement is about two hundred lines of Python. It reads the monitoring service’s REST API with a dedicated token, filters out the projects that are none of my business, applies a keyword based classification and writes the result. No AI anywhere on the path.&lt;/p&gt;

&lt;p&gt;The model did not disappear, it moved. Fine grained triage, phrasing a diagnosis, deciding whether to create a task: that stays as a layer on top, one that can fail without consequences. If it does not run, I lose commentary. Before, I lost the data.&lt;/p&gt;

&lt;p&gt;The difference fits in one sentence: the truth shown on my dashboard no longer depends on an assistant’s authentication state.&lt;/p&gt;

&lt;h2 id=&quot;this-was-not-the-first-warning&quot;&gt;This was not the first warning&lt;/h2&gt;

&lt;p&gt;A few weeks earlier, the agent that watches the other agents started sending me outage alerts for services that were perfectly healthy. The reason is that it computed the current timestamp itself, and the model got the day wrong in certain time windows. Eight false alerts before I figured it out.&lt;/p&gt;

&lt;p&gt;The fix was to forbid it from producing a date at all. A date is something you ask the operating system for, not something you infer.&lt;/p&gt;

&lt;p&gt;Here again, “AI is unreliable” misses the point. A language model is non deterministic by construction, and that is exactly what you want from it when you need rephrasing, summarising or judgement calls. The problem only shows up when you also hand it tasks where non determinism is a defect: counting, dating, adding up, fetching a value that already exists somewhere.&lt;/p&gt;

&lt;h2 id=&quot;the-rule-i-apply-now&quot;&gt;The rule I apply now&lt;/h2&gt;

&lt;p&gt;My personal system currently runs forty eight active agents. Twenty nine of them contain no AI at all. They are scripts. They read an API, compute, write a file, send a notification. They run on time, they cost nothing, and their failure mode is legible.&lt;/p&gt;

&lt;p&gt;The other nineteen use a model because they do something a script cannot: read a client conversation and extract an intent, draft a text, rank heterogeneous signals, propose three options and recommend one.&lt;/p&gt;

&lt;p&gt;That is where I draw the line. AI above the system, for judgement. Never inside it, on the path where data is acquired or computed.&lt;/p&gt;

&lt;h2 id=&quot;the-question-to-ask-before-putting-it-everywhere&quot;&gt;The question to ask before putting it everywhere&lt;/h2&gt;

&lt;p&gt;For an executive evaluating an automation with AI inside, the useful question goes further than “does the demo work”. It comes in two parts.&lt;/p&gt;

&lt;p&gt;If the model is unavailable for three days, what exactly stops? And does anyone notice, or does the screen keep showing numbers that look normal?&lt;/p&gt;

&lt;p&gt;If the second part is fuzzy, the problem sits with the dependency rather than the AI. It was added without being held to the standards every other dependency is held to: failure detection, an explicit degraded mode, and a clear assumption about what happens when it is not there.&lt;/p&gt;

&lt;p&gt;An automation that cannot tell you it is down is not an automation, it is a display.&lt;/p&gt;

&lt;p&gt;If you have a process where AI settled in without that line ever being drawn, we can look together at what deserves to stay and what would be better off as a script again.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="ai" />
    <category term="AI" />
    <category term="Automation" />
    <category term="Reliability" />
    <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.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">E-invoicing 2026: the real work isn&#39;t the format</title>
    <link href="https://sxnlabs.com/en/opensource/2026/07/23/facturation-electronique-2026-vrai-chantier/" rel="alternate" type="text/html" title="E-invoicing 2026: the real work isn&#39;t the format" />
    <published>2026-07-23T09:00:00+02:00</published>
    <updated>2026-07-23T09:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/opensource/2026/07/23/facturation-electronique-2026-vrai-chantier/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/opensource/2026/07/23/facturation-electronique-2026-vrai-chantier/">&lt;p&gt;The French e-invoicing reform is coming, and the question everyone asks is the wrong one. Not “which format?”, not “which platform?”, but whether your software already produces a clean electronic invoice, edge cases included.&lt;/p&gt;

&lt;p&gt;A 30-second recap. Every VAT-registered business in France is concerned, down to the smallest ones. Receiving becomes mandatory in September 2026, and issuing follows in September 2027 for small and mid-sized companies, as early as 2026 for large and intermediate-sized ones. A non-compliant invoice is a flow rejected at the door, which means late payment, a penalty, and direct pressure on cash.&lt;/p&gt;

&lt;p&gt;Generating a Factur-X file is a few lines of code. The real work sits elsewhere: credit notes that have to reference the right invoice, SIREN, SIRET, VAT and IBAN validators, reconciliation with the business side, plugging into a certified platform or Chorus Pro. That is where most existing chains stall, because paper used to let through everything that structured XML refuses.&lt;/p&gt;

&lt;p&gt;This is why I wrote &lt;code class=&quot;highlighter-rouge&quot;&gt;einvoicing&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;einvoicing-connect&lt;/code&gt;, two open source gems published on RubyGems and already running in production. EN 16931 compliant Factur-X PDF/A-3 generation, validators included, a Chorus Pro and PPF client ready to wire up, with no third-party SaaS and no external dependency. Fitting them into an existing Rails system takes a few days.&lt;/p&gt;

&lt;p&gt;To find out where you stand, I put together a dedicated page with a 30-minute assessment. I look at your stack, your volumes and your target platform, and you leave with a concrete integration plan and a firm quote.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;/en/facturation-electronique-2026/&quot;&gt;See the 2026 e-invoicing page&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And to check whether a PDF is already compliant, &lt;a href=&quot;/en/factur-x-validator/&quot;&gt;a Factur-X validator is freely available&lt;/a&gt;, fully local analysis, nothing uploaded.&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="Compliance" />
    <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.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">My Postgres high availability wasn&#39;t, autopsy of an outage</title>
    <link href="https://sxnlabs.com/en/opinion/2026/07/22/panne-postgres-ha-illusoire-scaleway/" rel="alternate" type="text/html" title="My Postgres high availability wasn&#39;t, autopsy of an outage" />
    <published>2026-07-22T18:00:00+02:00</published>
    <updated>2026-07-22T18:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/opinion/2026/07/22/panne-postgres-ha-illusoire-scaleway/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/opinion/2026/07/22/panne-postgres-ha-illusoire-scaleway/">&lt;p&gt;Yesterday evening, every one of my internal services and the software I host for my clients became unreachable for several hours. A saturated Postgres database, a high-availability setup that did not play its part, and a Scaleway incident on top. Here is the autopsy, no filter, because an outage you tell honestly is worth more than one you paper over.&lt;/p&gt;

&lt;h2 id=&quot;the-trigger-or-rather-the-triggers&quot;&gt;The trigger, or rather the triggers&lt;/h2&gt;

&lt;p&gt;At 10:15 PM (Paris time), Scaleway lost a node in its block storage cluster in fr-par-1 (incident &lt;code class=&quot;highlighter-rouge&quot;&gt;bsp2y5fysy9w&lt;/code&gt;). On the infrastructure side the effect was immediate: virtual machines stuck, and above all managed databases frozen on storage that had become unreachable.&lt;/p&gt;

&lt;p&gt;At the very same moment, on my end, I had kicked off a batch of contact-enrichment jobs on Argos, my CRM. That kind of batch opens a lot of Postgres connections, and fast. Was it the batch that saturated the pool, or was the database already suffering from the Scaleway storage? I do not have a clean answer, and that is exactly the trap of correlated incidents: two plausible causes landing in the same window, and a doubt that only lifts after the fact. What I do know is that the two fed off each other. A database that responds poorly, connections piling up instead of being released, a batch that keeps asking for more. The pool hits its connection limit, and from there no application can open a single one. Everything falls in cascade.&lt;/p&gt;

&lt;h2 id=&quot;the-real-problem-an-ha-setup-that-wasnt-one&quot;&gt;The real problem: an HA setup that wasn’t one&lt;/h2&gt;

&lt;p&gt;My database ran in high availability, two nodes. Reassuring on paper. Except that both nodes lived in the same datacenter, fr-par-1. When the block storage layer of fr-par-1 went down, my two replicas left together, in the same second. That is the lesson I hold onto the most: redundancy only protects against what it isolates. Mine covered the failure of a process or a machine, not that of an entire zone. I had taken out insurance against one risk, not against all of them.&lt;/p&gt;

&lt;p&gt;Worse, restarting the database was impossible. The managed Postgres engine stayed hooked to the failing storage. I could kill the zombie connections and restart my applications all I wanted, the final recovery no longer depended on me: it was suspended on Scaleway’s repair. Finding yourself a spectator of your own outage, with no lever at all, is the most unpleasant moment of the evening.&lt;/p&gt;

&lt;h2 id=&quot;getting-out-of-it&quot;&gt;Getting out of it&lt;/h2&gt;

&lt;p&gt;Things unblocked during the night, by combining three things. I cut the connections left open on the Postgres side, I restarted the applications to start again from clean pools, and Scaleway eventually repaired the storage node. That last point was the decisive factor. Without it, the rest was useless. My first two actions only set the stage so everything could come back cleanly once the infrastructure returned.&lt;/p&gt;

&lt;h2 id=&quot;what-i-take-away-and-what-i-have-already-done&quot;&gt;What I take away, and what I have already done&lt;/h2&gt;

&lt;p&gt;A good incident is the one that finally makes you do what you were putting off. I tackled three things right away.&lt;/p&gt;

&lt;p&gt;PgBouncer in front of Postgres, first. A connection pooler I had wanted to set up for a long time without ever getting to it. It caps the number of connections and stops a batch from starving every other application. It is in place.&lt;/p&gt;

&lt;p&gt;A read-only failover in another zone, next. My two HA nodes in the same datacenter were the heart of the problem. I added a read-only replica elsewhere, so that a Scaleway zone going down no longer takes everything with it in one block.&lt;/p&gt;

&lt;p&gt;Observability on connections, finally. Watching &lt;code class=&quot;highlighter-rouge&quot;&gt;pg_stat_activity&lt;/code&gt; and how full the pool is, to see saturation climb and fire an alert before it kills everything, instead of discovering it once the service is already down.&lt;/p&gt;

&lt;h2 id=&quot;the-lesson-in-one-sentence&quot;&gt;The lesson, in one sentence&lt;/h2&gt;

&lt;p&gt;An infrastructure dependency, however solid on paper, stays a single point of failure until you have explicitly isolated it. And redundancy you have never tested against the right failure mode is redundancy in name only.&lt;/p&gt;

&lt;p&gt;If you have infrastructure you keep calling “highly available” without having checked against which kind of failure exactly, we can take a look together, rather than finding out on some Tuesday evening.&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="High availability" />
    <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.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">How much am I owed, in total, right now?</title>
    <link href="https://sxnlabs.com/en/side-project/2026/07/13/suivi-tresorerie-freelance-cash-a-encaisser/" rel="alternate" type="text/html" title="How much am I owed, in total, right now?" />
    <published>2026-07-13T18:00:00+02:00</published>
    <updated>2026-07-13T18:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/side-project/2026/07/13/suivi-tresorerie-freelance-cash-a-encaisser/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/side-project/2026/07/13/suivi-tresorerie-freelance-cash-a-encaisser/">&lt;blockquote&gt;
  &lt;p&gt;All financial figures and client names in this article are fictional. The screenshots show real screens, filled with made-up data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When you run a company on your own, one question comes up more often than any other. Not “how much did I invoice last month”, not “what’s my yearly revenue”. The real question, the one that decides everything, is: &lt;strong&gt;how much am I owed, in total, that I’m going to collect?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not within a 30 or 90 day window. In total. Everything invoiced and not yet paid, with no time limit, and whatever is overdue clearly flagged.&lt;/p&gt;

&lt;p&gt;And the problem was that nothing answered it. My accounting software told me what was invoiced. My CRM what was in the pipe. My bank account what had already landed. Three partial truths, in three different tools, that I had to cross-reference by hand in a spreadsheet on a Sunday evening. Which is to say: never.&lt;/p&gt;

&lt;p&gt;So I stitched the pieces back together at the source, inside Argos.&lt;/p&gt;

&lt;h2 id=&quot;argos-from-crm-to-treasury-cockpit&quot;&gt;Argos, from CRM to treasury cockpit&lt;/h2&gt;

&lt;p&gt;Argos is the CRM I built for SXN Labs. It started out managing contacts, deals and quotes. The latest project was to graft a treasury-tracking layer onto it, with one rule I set for myself: show facts, not predictions. I didn’t want a model guessing my future. I wanted the truth at time T, the kind I can verify line by line.&lt;/p&gt;

&lt;p&gt;The result is a dashboard whose default state is a sentence I love to read: &lt;strong&gt;“Nothing needs your attention.”&lt;/strong&gt; When something is off, it surfaces it. When everything is fine, it says so, and then it shuts up. At the top of the screen, a handful of numbers, all factual, all up to date, without me entering anything.&lt;/p&gt;

&lt;figure&gt;
  &lt;img src=&quot;/images/posts/argos/argos-dashboard.en.png&quot; alt=&quot;Argos treasury dashboard: cash to collect, ex-VAT, VAT and overdue invoices (fictional data)&quot; /&gt;
  &lt;figcaption&gt;The Argos Cash dashboard at a glance (fictional data).&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Under that calm surface, there are three small technical challenges I find interesting.&lt;/p&gt;

&lt;h2 id=&quot;all-the-pending-cash-at-once&quot;&gt;All the pending cash, at once&lt;/h2&gt;

&lt;p&gt;The central number is “to collect”: the sum of every invoice issued and not yet paid. No sliding window, no arbitrary horizon, just the total.&lt;/p&gt;

&lt;p&gt;But a raw amount isn’t enough. What I want to see at a glance is the ex-VAT figure (what’s actually mine), the VAT share (which I’m only holding for the tax office), and above all what’s &lt;strong&gt;overdue&lt;/strong&gt;. A client who has owed 8,400 € for ten days is not the same piece of information as a client who has until the end of the month to pay me. The first calls for a reminder, the second doesn’t.&lt;/p&gt;

&lt;p&gt;Argos computes that ex-VAT / incl-VAT / overdue split from Pennylane invoices. And this is where using Pennylane as both bank &lt;em&gt;and&lt;/em&gt; accounting software pays off. No bank reconciliation to do, since payments are natively matched to invoices, transactions arrive already categorized. I consume the data as-is, I don’t reclassify anything by hand.&lt;/p&gt;

&lt;h2 id=&quot;two-accounts-one-treasury&quot;&gt;Two accounts, one treasury&lt;/h2&gt;

&lt;p&gt;My treasury lives in two places. Pennylane for day-to-day banking. Spiko for idle cash, parked in tokenized money-market funds that earn a little every day.&lt;/p&gt;

&lt;p&gt;Total treasury is therefore the sum of the two pools. Because they are physically distinct, a bank account on one side and fund shares on the other, there’s no risk of counting the same euro twice. And if one of the two sources goes down, the system falls back cleanly on the other rather than showing a phantom balance.&lt;/p&gt;

&lt;p&gt;A small anecdote for anyone who has integrated an API before: Spiko returns a lovely &lt;strong&gt;403 from Cloudflare&lt;/strong&gt; if you don’t send a browser User-Agent. The classic “your &lt;code class=&quot;highlighter-rouge&quot;&gt;urllib&lt;/code&gt; is blacklisted by default”. Five minutes of confusion, one header, and you move on.&lt;/p&gt;

&lt;h2 id=&quot;and-everything-else-in-one-place&quot;&gt;And everything else, in one place&lt;/h2&gt;

&lt;p&gt;Once cash-to-collect and treasury were in place, I added the other facts that weigh on a small company: monthly recurring revenue, what was actually collected this month, and an estimate of the dividend I could pay myself, net of tax. Always the same logic: observed numbers, not promises.&lt;/p&gt;

&lt;p&gt;And because Argos isn’t only a financial tool, the same screen also aggregates the operational side: are my deployed applications healthy, how many servers are tracked, which deploys happened today, is my inbox up to date, are my mileage entries logged. Finance, ops and admin, in a single glance. All crowned by that famous “nothing needs your attention” when the checklist is green.&lt;/p&gt;

&lt;h2 id=&quot;the-twist-less-interface-not-more&quot;&gt;The twist: less interface, not more&lt;/h2&gt;

&lt;p&gt;Here’s the counter-intuitive part.&lt;/p&gt;

&lt;p&gt;The natural tendency, when you add data to a tool, is to pile on screens, charts and filters. I did the opposite. I &lt;strong&gt;shrank Argos’s interface surface to the minimum&lt;/strong&gt;, and I now drive it almost entirely through MCP.&lt;/p&gt;

&lt;p&gt;For the uninitiated, MCP (Model Context Protocol) lets you expose an application’s functions as tools an assistant can use. Concretely, Argos exposes tools like &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;. As a result, I barely click anymore. I &lt;em&gt;talk&lt;/em&gt; to my CRM. “Summarize the treasury”, “which quotes are signed but not yet invoiced”, “create a deal for this prospect”. The answer comes back, or the action happens, without me opening a single page.&lt;/p&gt;

&lt;p&gt;What I understood while building this is that for a tool I use alone, the most efficient interface isn’t a pretty web page. It’s a good, well-described API that an AI can operate on my behalf. The graphical UI becomes the exception, reserved for the moments when I actually want to &lt;em&gt;see&lt;/em&gt; something.&lt;/p&gt;

&lt;p&gt;A word on trust, because the question always comes up: this whole financial pipeline is &lt;strong&gt;read-only&lt;/strong&gt;. No invoice creation, no transfer, no automated movement of money. The system reads, aggregates, displays. It never touches the money. It’s a sensor, not a hand.&lt;/p&gt;

&lt;h2 id=&quot;cash-in-sight-without-going-to-look&quot;&gt;Cash in sight, without going to look&lt;/h2&gt;

&lt;p&gt;One last detail remained, and it might be the most pleasant one day to day.&lt;/p&gt;

&lt;p&gt;A dashboard, however accurate, is useless if it’s buried in a tab I open once a week. So I took it off the screen. I put a small e-ink display, a &lt;a href=&quot;https://trmnl.com/&quot;&gt;TRMNL&lt;/a&gt;, on the shelf in my office. It cycles the Argos Cash screen (to sign, to invoice, to collect, treasury, overdue invoices, collected this month) alongside other CRM views and more general ones: the weather, a photo, and the rest.&lt;/p&gt;

&lt;figure&gt;
  &lt;img src=&quot;/images/posts/argos/trmnl-eink.jpg&quot; alt=&quot;E-ink TRMNL display showing the Argos treasury screen on a desk&quot; /&gt;
  &lt;figcaption&gt;The TRMNL on my desk: treasury, ambient (fictional data).&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The effect is surprisingly strong. Financial information is no longer something I go and &lt;em&gt;fetch&lt;/em&gt;: it drifts through my field of view several times a day, between the weather and a photo, next to the hat and the sunglasses. I look up, and often enough, I know where I stand. No notification, no app to open. Just a calm number, updated on its own, in black and white on electronic paper.&lt;/p&gt;

&lt;h2 id=&quot;what-i-take-away&quot;&gt;What I take away&lt;/h2&gt;

&lt;p&gt;Building this thing for myself confirmed a conviction: the best tools you can make are often the ones you use first yourself, with real needs and real irritations. Argos’s treasury tracking was born from my own question, the one about the cash I’m owed. And that demand, the kind that lets nothing slide, ends up seeping into the product I offer my clients.&lt;/p&gt;

&lt;p&gt;Three ideas worth keeping: stitch the data back together at the source instead of in a spreadsheet, show verifiable facts rather than predictions, and accept that the best interface is sometimes the one that doesn’t exist.&lt;/p&gt;

&lt;p&gt;Next time you wonder how much you’re really owed, ask yourself another question: does your tool answer you at a glance, or do you still have to open a spreadsheet on a Sunday evening?&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">How I track my whole treasury and every euro still owed at a glance, inside Argos, my own CRM, with no time window and strictly read-only.</summary>
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/posts/argos/argos-dashboard.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">Ripping Algolia out of a healthcare app, the dependency that no longer earned its place</title>
    <link href="https://sxnlabs.com/en/opinion/2026/07/06/retirer-algolia-logiciel-sante-postgres-suffisait/" rel="alternate" type="text/html" title="Ripping Algolia out of a healthcare app, the dependency that no longer earned its place" />
    <published>2026-07-06T18:00:00+02:00</published>
    <updated>2026-07-06T18:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/opinion/2026/07/06/retirer-algolia-logiciel-sante-postgres-suffisait/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/opinion/2026/07/06/retirer-algolia-logiciel-sante-postgres-suffisait/">&lt;p&gt;On ORIGAMI, the healthcare business application I build for SOS Médecins (&lt;a href=&quot;https://sxnlabs.com/en/clients/healthcare/sos-medecins-origami-modernisation/&quot;&gt;see the case study&lt;/a&gt;), patient search ran on Algolia. A hosted search service, fast, forgiving of typos. Last week I unplugged it entirely. The whole change comes down to two numbers: 33 lines added, 331 removed. And on the user’s side, nobody noticed a thing. That was exactly the outcome I was after.&lt;/p&gt;

&lt;h2 id=&quot;why-algolia-was-there&quot;&gt;Why Algolia was there&lt;/h2&gt;

&lt;p&gt;The reflex is a familiar one. You need a “real” search, instant, typo-tolerant, surfacing the right patient even when you fat-finger three letters. You glance at Postgres, decide it’ll be a hassle, and wire in a specialised service. Algolia does that job very well. Within a few hours the search feels smooth and the matter seems settled.&lt;/p&gt;

&lt;p&gt;Except “wiring in Algolia” is never a single line. In the code it meant a Ruby gem, an npm package on the front end, an initializer to configure, API keys exposed to the browser, a job that reindexed patients into Algolia in parallel with the database on every change, and three Stimulus controllers talking directly to the Algolia client. A dependency isn’t a button you flip: it’s a surface you adopt, upgrade, monitor, and pay for every month.&lt;/p&gt;

&lt;h2 id=&quot;the-real-requirement-once-you-lay-it-flat&quot;&gt;The real requirement, once you lay it flat&lt;/h2&gt;

&lt;p&gt;The question I hadn’t asked myself in a long time: what search do I actually need here?&lt;/p&gt;

&lt;p&gt;The answer is modest. We look up patients within the scope of a care structure. The dataset runs to hundreds of thousands of records, not tens of millions. The fields are simple: a last name, a first name, a few identifiers. The real need is to find “Smith” when you type “smth”, and to find it fast.&lt;/p&gt;

&lt;p&gt;For that, Postgres is more than enough, even at that scale. The &lt;code class=&quot;highlighter-rouge&quot;&gt;pg_trgm&lt;/code&gt; extension computes trigram similarity: it absorbs typos and partial prefixes without you writing a single distance algorithm by hand. A dedicated table materialises each patient’s search document, a job keeps it fresh on change, and the query runs in the same database, in the same transaction, with no network round-trip to a third party. That internal backend already existed and had been the default for a few weeks. Algolia was just a second engine we kept around out of caution.&lt;/p&gt;

&lt;h2 id=&quot;the-real-argument-health-data-on-the-move&quot;&gt;The real argument: health data on the move&lt;/h2&gt;

&lt;p&gt;&lt;abbr title=&quot;Keep It Simple, Stupid&quot;&gt;KISS&lt;/abbr&gt; is a good reason. It isn’t the best one here.&lt;/p&gt;

&lt;p&gt;Sending patient names to a search index hosted abroad means letting health data leave its own infrastructure. It pulls in health-data hosting rules, GDPR, the principle of data minimisation. Even when everything is contractually airtight, it’s one more governance surface: another place sensitive data flows through, another processor to audit, another clause to defend the day someone asks the question.&lt;/p&gt;

&lt;p&gt;So removing Algolia isn’t just dropping a dependency and a €250-a-month bill. It’s removing a reason to worry. Patient data no longer leaves the database. There’s no external index to secure, no API keys to leak from the front end, no processing agreement to keep an eye on, for a feature we already knew how to do in-house.&lt;/p&gt;

&lt;h2 id=&quot;what-the-switch-was-really-about-nobody-feeling-a-thing&quot;&gt;What the switch was really about: nobody feeling a thing&lt;/h2&gt;

&lt;p&gt;Swapping one search engine for another on software used in production isn’t a matter of flipping a global switch. The bar was clear. On the user’s side, search response times had to stay rigorously identical. Not “roughly as fast”, identical. A patient search that lags by a tenth of a second, on a dispatch desk, gets noticed immediately.&lt;/p&gt;

&lt;p&gt;To validate that without risk, I’d added a per-user boolean, &lt;code class=&quot;highlighter-rouge&quot;&gt;internal_patient_search&lt;/code&gt;, that turned the new engine on for a handful of accounts only. A selective activation mechanism, not a forgotten flag: long enough to run both backends side by side, compare response times in real conditions, and confirm that the move to Postgres was felt nowhere.&lt;/p&gt;

&lt;p&gt;Once that validation was done, the flag had done its job. I removed it at the same time as Algolia, in the same pass: the database column, the checkbox in settings, the permitted params in the controller, the labels in two languages, and three now-pointless tests. A single boolean already touches six corners of the code; the discipline is less about never adding one than about dismantling it the moment it has stopped being useful.&lt;/p&gt;

&lt;h2 id=&quot;what-this-is-not&quot;&gt;What this is not&lt;/h2&gt;

&lt;p&gt;No case against Algolia here. It’s an excellent product, and there are contexts where I’d wire it back in without hesitation: a large e-commerce catalogue, a public-facing search, typo tolerance across millions of documents. The tool isn’t the point.&lt;/p&gt;

&lt;p&gt;The point is the question you ask before adopting it. Not “what’s the best search?”, but “what search do I need, for this dataset, in this context?”. When you take the time to answer that honestly, you often avoid importing a dependency, a recurring bill, and, in healthcare, a piece of sensitive data that had no reason to travel.&lt;/p&gt;

&lt;h2 id=&quot;simplicity-is-a-decision&quot;&gt;Simplicity is a decision&lt;/h2&gt;

&lt;p&gt;Absorbing complexity on the provider’s side so the software stays simple for the client applies just as much to the complexity you inflict on yourself. The best code isn’t the one you write most elegantly: it’s the one you remove because you no longer need it, and will never have to maintain again. 331 fewer lines, one fewer dependency, health data that stays home, and a strictly identical interface for the user, response times included. That’s what a good day looks like.&lt;/p&gt;

&lt;p&gt;If you’ve got an external dependency under the hood costing you every month without you really knowing what it buys you, we can look together at what deserves to stay and what can come back home.&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="Data sovereignty" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-07-06-retirer-algolia-logiciel-sante-postgres-suffisait.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">AI didn&#39;t replace my job, it moved my ceiling</title>
    <link href="https://sxnlabs.com/en/opinion/2026/06/22/ia-deplace-mon-plafond/" rel="alternate" type="text/html" title="AI didn&#39;t replace my job, it moved my ceiling" />
    <published>2026-06-22T11:00:00+02:00</published>
    <updated>2026-06-22T11:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/opinion/2026/06/22/ia-deplace-mon-plafond/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/opinion/2026/06/22/ia-deplace-mon-plafond/">&lt;p&gt;People regularly ask me whether AI is going to replace developers. The question is backwards. On my projects, AI hasn’t removed a single skill. It moved a constraint I had taken for granted for ten years: my own time.&lt;/p&gt;

&lt;h2 id=&quot;the-real-ceiling-was-never-the-code&quot;&gt;The real ceiling was never the code&lt;/h2&gt;

&lt;p&gt;When you build a serious line-of-business application alone or in a pair, what caps your output is the number of hours a human can sustain in a week without breaking quality. A small editor spends their life arbitrating: this week I wire up that integration, so I don’t touch the billing module; this quarter I push a certification through, so the mobile rework waits.&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; puts it well in a recent post: if AI hasn’t triggered a wave of developer layoffs, it’s because coding is only a small part of the job. Out of 160 companies that filed layoff notices in New York in 2025, &lt;em&gt;“not a single one checked the AI box.”&lt;/em&gt; Deciding what to build, specifying it, verifying it, being accountable for what you ship, understanding the business context, none of that automates.&lt;/p&gt;

&lt;p&gt;I agree. But I draw a slightly different conclusion than he does. If code is only a small part of the job, then accelerating the code doesn’t change the &lt;em&gt;nature&lt;/em&gt; of the work, it changes its &lt;em&gt;capacity&lt;/em&gt;. And for a small outfit, capacity is almost everything.&lt;/p&gt;

&lt;h2 id=&quot;what-it-concretely-changes&quot;&gt;What it concretely changes&lt;/h2&gt;

&lt;p&gt;The way I work shifted on scoping, on pricing and on running the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scoping.&lt;/strong&gt; I used to instinctively drop the “correct but expensive” features: the clean degraded mode, the monitoring, the integration tests on an external feed. Not out of laziness, out of a time budget. Today the marginal cost of doing it right has dropped. I wire in the safety net because writing it no longer costs me half a day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing.&lt;/strong&gt; A fixed-price quote is a bet on time. When the output ceiling rises, the same scope ships faster, or a more ambitious scope fits the same envelope. That doesn’t mean billing less; it means delivering more value for the price, and moving on to the next job sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running the project.&lt;/strong&gt; More back-and-forth with the client, and earlier. Mocking up a variant in an afternoon instead of a week means being able to say “look, here’s what it looks like” before freezing a decision. Velocity isn’t there to produce more lines. It exists to shorten the loop between a business idea and something you can actually touch.&lt;/p&gt;

&lt;h2 id=&quot;what-it-doesnt-change-and-the-anxiety-that-comes-with-it&quot;&gt;What it doesn’t change (and the anxiety that comes with it)&lt;/h2&gt;

&lt;p&gt;There’s another, darker reading going around among developers. A &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;post that circulated recently&lt;/a&gt; tells the same story from the inside: ten years building deep expertise (finance, debugging, architecture) only to watch it become &lt;em&gt;“promptable”&lt;/em&gt; in a few weeks. The anxiety isn’t unemployment, it’s the commoditization of mastery.&lt;/p&gt;

&lt;p&gt;I understand the vertigo. But I think it conflates two things: &lt;em&gt;knowing how&lt;/em&gt;, and &lt;em&gt;knowing what to do and being accountable for it&lt;/em&gt;. AI makes the first abundant. The second (deciding, specifying, verifying, carrying responsibility for what ships to production on a sensitive system) stays rare, and stays mine.&lt;/p&gt;

&lt;p&gt;The job doesn’t disappear. It moves up a notch, toward less production and more judgment.&lt;/p&gt;

&lt;h2 id=&quot;stepping-back&quot;&gt;Stepping back&lt;/h2&gt;

&lt;p&gt;Let me zoom out, owning that this is an opinion and not a proof.&lt;/p&gt;

&lt;p&gt;There’s a lot of talk about France’s demographic problem: an inverting age pyramid, the large cohorts retiring, a thinner generation behind them. Public debate fixates on a single variable, the retirement age. It’s one variable among several. Producing more per head is another, and tooling that raises one worker’s capacity feeds directly into it.&lt;/p&gt;

&lt;p&gt;I’m not claiming AI fixes demographics, that would be exactly the kind of marketing shortcut I spend my time criticizing. But when one worker can carry a volume of output that took two yesterday, it counts in the equation. It’s less divisive than moving an age slider, and probably more durable. Provided the gains go to the value produced, not to stacking tools for the sake of tooling.&lt;/p&gt;

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

&lt;p&gt;AI didn’t replace my job. It removed the dumbest limit weighing on it: the fact that a day has twenty-four hours. What stays rare (deciding, specifying, verifying, being accountable) hasn’t moved an inch. That’s precisely where the value of an editor lives, solo or not.&lt;/p&gt;

&lt;p&gt;If you run a company and you’re wondering where AI has a real effect rather than a cosmetic one, that’s exactly the kind of context we can look at together, and settle what genuinely deserves to be automated.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="opinion" />
    <category term="AI" />
    <category term="Productivity" />
    <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.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">E-invoicing 2026: the part the platform won&#39;t do for you</title>
    <link href="https://sxnlabs.com/en/opensource/2026/06/08/facture-electronique-2026-reception/" rel="alternate" type="text/html" title="E-invoicing 2026: the part the platform won&#39;t do for you" />
    <published>2026-06-08T15:30:00+02:00</published>
    <updated>2026-06-08T15:30:00+02:00</updated>
    <id>https://sxnlabs.com/en/opensource/2026/06/08/facture-electronique-2026-reception/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/opensource/2026/06/08/facture-electronique-2026-reception/">&lt;p&gt;September 2026 is everywhere. In public campaigns, in tax authority emails, in conversations with accountants. The date has entered the scenery, which is not a bad thing: at least this time, nobody should discover the reform the night before with a surprised face and three spreadsheets open.&lt;/p&gt;

&lt;p&gt;The problem is not that nobody talks about September 2026. The problem is what people put behind that date. Many still hear it as “the start of e-invoicing”, or as the deadline for large companies that must start issuing. For a small business, the concrete effect is more immediate: &lt;strong&gt;from 1 September 2026, your suppliers may send you real electronic invoices, and you must be able to receive them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Receiving comes before issuing, and it’s exactly the part you don’t control. But the real question, behind the date, isn’t &lt;em&gt;which tool to buy&lt;/em&gt;: it’s &lt;em&gt;who is going to wire all this into your software&lt;/em&gt;. And there, neither the platform nor the accountant holds the role.&lt;/p&gt;

&lt;h2 id=&quot;what-changed-in-the-system&quot;&gt;What changed in the system&lt;/h2&gt;

&lt;p&gt;For two years, the announced design relied on the PPF (the public invoicing portal) as a platform invoices could transit through. The system has since been narrowed: &lt;strong&gt;the PPF is no longer the public transit platform companies were expecting, but the infrastructure for the central directory and for reporting data back to the tax authority.&lt;/strong&gt; Its job is to know who receives what, through which platform, and to concentrate the tax data needed by the DGFiP.&lt;/p&gt;

&lt;p&gt;Concretely, that means three things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;B2B invoice transit now goes through &lt;strong&gt;certified platforms&lt;/strong&gt; (the former PDPs), registered by the tax authority. No simple public option where everyone deposits and retrieves invoices.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Chorus Pro remains the reference platform for B2G&lt;/strong&gt;, invoicing the public sector doesn’t change channel.&lt;/li&gt;
  &lt;li&gt;Penalties have been strengthened: the per-invoice fine goes from &lt;strong&gt;€15 to €50 per invoice&lt;/strong&gt;, e-reporting from &lt;strong&gt;€250 to €500 per transmission&lt;/strong&gt; (with annual caps).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the official list at the time of publication, examples include familiar names such as Pennylane, Qonto, Sage, Cegid, Tiime, Indy, Yooz or Esker. This is not a recommendation list, just a useful reminder that reception won’t go through a magic public form, but through an intermediary you actually choose.&lt;/p&gt;

&lt;p&gt;The operational calendar remains easy to remember: &lt;strong&gt;mandatory reception for everyone on 1 September 2026&lt;/strong&gt;, issuance for large companies and mid-caps on the same date, issuance for SMEs and micro-businesses a year later.&lt;/p&gt;

&lt;h2 id=&quot;why-reception-is-the-trap&quot;&gt;Why reception is the trap&lt;/h2&gt;

&lt;p&gt;When people talk about September 2026, the natural reflex is to ask: “do I have to issue?” If you’re an SME or a micro-business, the answer is often no, not yet. That’s where the misunderstanding starts.&lt;/p&gt;

&lt;p&gt;When you issue, you hold the schedule. You pick your platform, you migrate when you’re ready, you test on your own flows.&lt;/p&gt;

&lt;p&gt;Receiving is the opposite: &lt;strong&gt;your supplier decides for you.&lt;/strong&gt; From 1 September 2026, the moment one of your large or mid-cap suppliers switches to electronic issuance (and they’re required to by that date), you must be able to ingest their invoice. You choose neither the timing, nor the format, nor the platform on the other side. You’re on the receiving end, literally.&lt;/p&gt;

&lt;p&gt;And “being able to receive” isn’t having an inbox. In the sense of the reform, an electronic invoice takes one of these baseline formats:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;a pure &lt;strong&gt;structured XML&lt;/strong&gt; (UBL or CII),&lt;/li&gt;
  &lt;li&gt;or a &lt;strong&gt;Factur-X&lt;/strong&gt;: a human-readable PDF/A-3 with the same CII XML embedded inside for the machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can’t tell them apart by eye: both open in a PDF reader. To find out which category your software currently produces, &lt;a href=&quot;/en/factur-x-validator/&quot;&gt;drop one of your invoices into the Factur-X pre-check&lt;/a&gt;, the analysis runs in your browser, the file goes nowhere.&lt;/p&gt;

&lt;p&gt;Receiving properly therefore means connecting to a certified platform, pulling the flow, &lt;strong&gt;parsing the XML&lt;/strong&gt;, validating it, then reconciling it against your purchase orders and your bookkeeping. It’s an integration project, not a setting to toggle. And it’s exactly the kind of work you underestimate until you’ve done it.&lt;/p&gt;

&lt;h2 id=&quot;the-format-isnt-the-problem&quot;&gt;The format isn’t the problem&lt;/h2&gt;

&lt;p&gt;I wrote two open-source gems around this: &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; for the core (generating and reading Factur-X, validation, CII profiles) and &lt;code class=&quot;highlighter-rouge&quot;&gt;einvoicing-connect&lt;/code&gt; for the plumbing (Chorus Pro, certified platforms, e-reporting). After running them on real flows, one thing became obvious: &lt;strong&gt;the format is the easy part.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Producing a compliant Factur-X is mechanical. A PDF/A-3, a CII XML attached with the right filename and the right profile, a handful of validation rules. A library does this very well:&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;Service&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 + embedded CII XML&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The real work is elsewhere, in the &lt;strong&gt;business reconciliation.&lt;/strong&gt; An incoming invoice has to be linked to the right supplier, the right purchase order, the right VAT, and you have to handle credit notes, partial invoices, cent-level discrepancies, identifiers that never match on the first try. The XML hands you clean data; it doesn’t tell you what to do with it inside &lt;em&gt;your&lt;/em&gt; accounting. Reconciliation is where 90% of the effort goes, and no platform will do that part for you.&lt;/p&gt;

&lt;h2 id=&quot;the-role-nobody-holds&quot;&gt;The role nobody holds&lt;/h2&gt;

&lt;p&gt;Look at who actually shows up on this. The certified platform delivers a clean flow to your door. The accountant reads the result once it’s in the books. Between the two, there’s a space neither of them covers: making &lt;em&gt;your&lt;/em&gt; software (your business app, your ERP, the spreadsheet everything revolves around) ingest that invoice, reconcile it, and do something useful with it. That space is integration. That’s a developer’s job.&lt;/p&gt;

&lt;p&gt;Concretely, it comes down to three pieces:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The plumbing.&lt;/strong&gt; Connecting your existing software to the certified platform’s API so the incoming flow lands &lt;em&gt;inside&lt;/em&gt; your system, not in yet another portal someone has to check by hand. Nobody else knows your data model.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The reconciliation.&lt;/strong&gt; The famous 90%: linking each received invoice to the right supplier, the right purchase order, the right VAT, handling credit notes and discrepancies, inside &lt;em&gt;your&lt;/em&gt; business model. No platform will do it, because no platform knows your business.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The lifecycle.&lt;/strong&gt; The reform requires you to report invoice statuses (received, refused, paid…). Those transitions have to live in your real workflow, not in a separate interface nobody will ever update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s where I sit, and it’s what I wanted to make clear here: &lt;strong&gt;I don’t sell a platform. I make your existing software speak the language of the reform (ingest, reconcile, report), where the platform stops and your business begins.&lt;/strong&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-right-question-to-ask-now&quot;&gt;The right question to ask now&lt;/h2&gt;

&lt;p&gt;The reform is not “2026 for large companies, 2027 for small ones”, but &lt;strong&gt;2026 for everyone on reception&lt;/strong&gt;, then 2027 for part of the market on issuance.&lt;/p&gt;

&lt;p&gt;So the useful question is not only “when do I have to issue?” It’s: &lt;strong&gt;“on 1 September 2026, can I receive a structured electronic invoice and integrate it without re-keying it by hand?”&lt;/strong&gt; If the answer is no, this isn’t next year’s problem. It’s this summer’s.&lt;/p&gt;

&lt;p&gt;Picking a certified platform, wiring up reception, testing the parsing on a few real supplier invoices: that’s a calm few-weeks job if you start now, and a painful one if you wait for the first supplier to switch without warning.&lt;/p&gt;

&lt;h2 id=&quot;preparing-reception-before-it-hurts&quot;&gt;Preparing reception before it hurts&lt;/h2&gt;

&lt;p&gt;A business application, an ERP held together around spreadsheets, an accounting workflow that still works because someone re-keys everything by hand: e-invoicing will quickly reveal the places where the system mostly runs on human patience.&lt;/p&gt;

&lt;p&gt;This is the kind of work I handle at &lt;a href=&quot;/en/contact/?ref=facture-electronique-reception&quot;&gt;SXN Labs&lt;/a&gt;: clarify the real need, choose the right level of automation, wire up reception, and make sure invoices land in the right place without adding yet another overbuilt system. If your e-invoicing reception is about to become a painful project, drop me a line.&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="Compliance" />
    <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.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">Hermes: automating my day without handing an AI the keys</title>
    <link href="https://sxnlabs.com/en/ai/2026/06/07/hermes-assistant-personnel/" rel="alternate" type="text/html" title="Hermes: automating my day without handing an AI the keys" />
    <published>2026-06-07T14:00:00+02:00</published>
    <updated>2026-06-07T14:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/ai/2026/06/07/hermes-assistant-personnel/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/ai/2026/06/07/hermes-assistant-personnel/">&lt;p&gt;Hermes is my personal assistant. Mine, not the large open source project that later took the same name. I had already called the folder &lt;code class=&quot;highlighter-rouge&quot;&gt;~/Hermes&lt;/code&gt; before that one shipped. The name was mostly obvious for a system that carries messages, signals and decisions, and the code has no ambition to become a generic product. It is useful to me precisely because it is so personal.&lt;/p&gt;

&lt;p&gt;I built it because part of my working life looks like that of many freelancers and small teams. Important things are scattered across Gmail, GitHub, Sentry, Pennylane, &lt;a href=&quot;https://culturedcode.com/things/&quot;&gt;Things&lt;/a&gt; (my task manager), Markdown files, internal dashboards and a few corners of my brain that would rather have been used for something else. A CI run that fails, a Sentry error that keeps coming back, a quote to follow up on, an invoice to track, a project almost finished that risks getting stuck. None of these is hard on its own. Their accumulation, on the other hand, ends up producing a fairly mediocre kind of mental load.&lt;/p&gt;

&lt;p&gt;The ridiculous pitch would be to say I built a “personal agentic operating system”. I could even add “AI-augmented” to make sure I lose everyone except two LinkedIn consultants and a prompt engineering course salesman. The reality is simpler. Hermes is a set of small local agents running on my Mac, with state files, Python scripts, a private dashboard, a few business integrations, and above all fairly strict rules about what they are allowed to do.&lt;/p&gt;

&lt;p&gt;Some agents use an LLM. Many do not. That distinction matters, because the right use of AI is not to replace every &lt;code class=&quot;highlighter-rouge&quot;&gt;if&lt;/code&gt; with a prayer billed by the million tokens. When a task is deterministic, a dumb reliable script is usually better. When heterogeneous signals need summarizing, an arbitration needs proposing or three clear options need laying out, an LLM can earn its place. Adding AI everywhere is easy. Knowing where it genuinely deserves to enter the loop is the hard part.&lt;/p&gt;

&lt;h2 id=&quot;a-personal-tool-not-a-generic-framework&quot;&gt;A personal tool, not a generic framework&lt;/h2&gt;

&lt;p&gt;I increasingly think AI becomes genuinely effective when it is embedded in a very personal tool, in the strict sense: a tool built for one person, with their reflexes, their blind spots, their priorities and their way of deciding. Hermes extends how I work rather than bolting onto it, and it carries my information sources, my risk thresholds, my decision habits, and the exact places where I want to take back control.&lt;/p&gt;

&lt;p&gt;That is why I am fairly wary of generic agent frameworks sold as shortcuts. They can be useful for prototyping, but their structure already carries a view of what work is: how memory behaves, how tasks are routed, what counts as success, when to escalate, how to notify, what qualifies as an action. Using someone else’s framework without questioning it often means adopting someone else’s logic. Sometimes it fits your problem. More often it fits the demo of whoever wrote it.&lt;/p&gt;

&lt;p&gt;We have seen this film before with React SPAs. For years, brochure sites, tiny back offices and rather ordinary forms absorbed Facebook’s complexity while believing they were becoming Facebook. What they mostly got was the mental cost, the oversized bundles, the fragile client state and the hydration bugs, without ever having the problem that justified the architecture. AI is walking the same path whenever people start by picking an agent stack before understanding the actual work to automate.&lt;/p&gt;

&lt;p&gt;So Hermes is deliberately built around my constraints. My inbox is my todo list. Things is where the real actions live. The dashboard carries passive information. Telegram is for urgent arbitrations. Clients, quotes, incidents and projects already have their sources of truth. The system does not get to invent a parallel world, it has to connect what already exists and interrupt me only when that is worth the cost.&lt;/p&gt;

&lt;h2 id=&quot;what-the-system-has-to-solve&quot;&gt;What the system has to solve&lt;/h2&gt;

&lt;p&gt;The starting need was not “having an agent”. I did not need one more chatbot to remember to talk to, to re-brief with context, and then to double-check. That is sometimes useful for thinking out loud or producing a draft, but it does not really remove the operational load. It moves that load into a chat window, with a more polished interface and the same problem behind it.&lt;/p&gt;

&lt;p&gt;What I wanted was to know each morning what deserves my attention, to avoid finding out too late about a blocked PR or a production error, to track quotes without turning my brain into a low-budget CRM, to keep client context available before a call, and above all to finish the things that are close to done instead of opening fifteen fronts at once. Most of these needs are prosaic. That is exactly why they matter. A useful tool usually starts by removing mundane friction, not by announcing that it will reinvent work.&lt;/p&gt;

&lt;p&gt;The boundary I set from the start is simple. Hermes may observe, classify, summarize, prepare, flag and propose. It must not mistake itself for me. It does not publish a quote, does not send a client email, does not make an architecture decision, does not modify a project beyond trivial fixes, and does not manufacture noise to prove it is working. I already have enough software that confuses activity with usefulness.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/hermes/dashboard-overview.jpg&quot; alt=&quot;Anonymized overview of the Hermes dashboard&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Anonymized overview. The dashboard keeps actions, signals and agent state in one place, without turning every piece of information into a notification.&lt;/em&gt;&lt;/p&gt;

&lt;h2 id=&quot;deliberately-ordinary-architecture&quot;&gt;Deliberately ordinary architecture&lt;/h2&gt;

&lt;p&gt;Hermes runs locally on my Mac. A scheduler managed by &lt;code class=&quot;highlighter-rouge&quot;&gt;launchd&lt;/code&gt; reads a &lt;code class=&quot;highlighter-rouge&quot;&gt;schedule.toml&lt;/code&gt; file, works out which agents are due, applies simple preconditions, then launches the jobs. Runs write their logs, their state, and an HTML dashboard page. Shared state lives in JSON or JSONL files, the more human notes are in Markdown, and deterministic tasks go through Python scripts.&lt;/p&gt;

&lt;p&gt;This architecture will impress nobody at an AI conference, and that is fine. I would rather have a system I can understand at 11pm, when something stops working, than a magical platform where three proprietary abstractions hide a cron, a queue and a prompt. The day it breaks, I want to open a file, read a log, work out which agent wrote what, and fix it. The rest is often decoration sold at SaaS prices.&lt;/p&gt;

&lt;p&gt;The scheduler also caps concurrency. It does not launch twenty agents because twenty agents exist. Some jobs run in pure Python, particularly where the logic is stable and an LLM call would only add cost and randomness. Others go through a model when they have to aggregate less structured signals. The mix is not very dogmatic, but it has the merit of being honest. AI is a component, not an architectural religion.&lt;/p&gt;

&lt;h2 id=&quot;agents-with-a-narrow-job&quot;&gt;Agents with a narrow job&lt;/h2&gt;

&lt;p&gt;Every agent has a tight scope. That is probably the most important choice in the project. A “general assistant” agent quickly swallows all available context and acts with a confidence inversely proportional to its actual understanding. An agent that reads one specific queue, produces one specific state and writes one specific dashboard is far more boring, and far more usable.&lt;/p&gt;

&lt;p&gt;The morning briefing prepares the start-of-day summary: PRs to review, failed tasks, overnight activity, Sentry signals, work queue. Inbox triage reads the Gmail inbox, since my inbox is deliberately a todo list. It classifies threads, spots the ones that are aging, and can draft simple administrative replies, but it never touches client or prospect mail. Project focus picks the three projects to push that day, favoring whatever is close to done, whatever got a client signal, or whatever risks getting stuck.&lt;/p&gt;

&lt;p&gt;Dev agent is tighter still. It can handle a few obvious Sentry errors, risk-free Dependabot merges, or mechanical CI fixes. It has no right to do product, architecture, refactoring or ambiguous application bugs. The slightest doubt falls out of scope. Frustrating for a demo, very healthy for a tool that touches real repositories.&lt;/p&gt;

&lt;p&gt;The business agents follow the same principle. Pennylane sync pulls invoices, transactions and balances to maintain a cash position, and stays read-only. Estimates tracks quotes and prepares proposals, but nothing is sent or published without explicit approval. Client context keeper rebuilds living client files from quotes, invoices, recent conversations, PRs and incidents, preserving the manual notes. None of this replaces human judgement. It gets me to a decision with the right context already in hand.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/hermes/project-focus.jpg&quot; alt=&quot;Anonymized project prioritization in Hermes&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Project Focus does not look for the most appealing project. It pushes whatever has to move forward: nearly finished work, recent client signals, explicit blockers.&lt;/em&gt;&lt;/p&gt;

&lt;h2 id=&quot;attention-as-a-product-constraint&quot;&gt;Attention as a product constraint&lt;/h2&gt;

&lt;p&gt;The biggest trap in automation is the system that works well enough to produce noise. A useless alert becomes an interruption, a repeated interruption becomes a reflex to ignore, and an ignored system ends up worse than no system at all, because it creates the impression that something is being watched when nobody is listening any more.&lt;/p&gt;

&lt;p&gt;So Hermes separates the channels. The dashboard holds passive information, Things holds the actions I actually have to take, Telegram covers emergencies and short arbitrations, and the state files keep history for the next runs. An invoice expected in three days is not necessarily an action. A client to follow up on is. An already merged PR must not spawn a fresh todo because an agent re-read an old JSON file with the administrative enthusiasm of a tax form on amphetamines.&lt;/p&gt;

&lt;p&gt;There is deduplication, there are anti-spam windows, there are “one subject, one todo” rules, and there are cases where Hermes explicitly decides to push nothing at me. Less spectacular than an avalanche of notifications, and it is what makes the system usable. Attention is a limited resource, and an assistant that wastes it is working against me, however statistically well-meaning its intentions.&lt;/p&gt;

&lt;h2 id=&quot;refusals-are-a-feature&quot;&gt;Refusals are a feature&lt;/h2&gt;

&lt;p&gt;The most important part of Hermes is not the list of what it can do, but the list of what it refuses. No automatic client email. No quote published without explicit approval. No code change beyond a trivial scope. No architecture decision. No Things todo for its own internal plumbing. No “I think that…” when the system could ask a clear question with three actionable options.&lt;/p&gt;

&lt;p&gt;This is where the product mindset really counts. When you discover agents, the natural reflex is to add capabilities: answer clients, publish quotes, merge PRs, decide the next piece of work, reorganize the schedule. Some of those may be reasonable one day. But the right question is not “is it technically possible?”. The right question is “which mistake becomes possible if I allow it?”.&lt;/p&gt;

&lt;p&gt;An agent that can do everything is not necessarily powerful. It often offers an accident surface with good UX. Hermes is useful because it knows to stop where my judgement is still needed: arbitrating a client need, accepting a commercial risk, approving a proposal, deciding that a request contradicts the reason a piece of software exists. Those decisions are not annoying details waiting to be automated, they are exactly where the value sits.&lt;/p&gt;

&lt;h2 id=&quot;memory-you-can-inspect&quot;&gt;Memory you can inspect&lt;/h2&gt;

&lt;p&gt;Hermes memory lives in files. JSON, JSONL, Markdown. Basic, versionable, inspectable, repairable. Each agent reads what it needs and writes into precise areas: dev queue, PR memory, failed tasks, project signals, answers to questions, client files.&lt;/p&gt;

&lt;p&gt;I have nothing against semantic search, and Hermes uses it where it adds something. But the operational memory of a working system has to stay readable. An agent that justifies a decision with “I found that in my memory”, with no way to inspect the source, is not intelligent. Nobody can audit it, which is a very modern way of being dangerous.&lt;/p&gt;

&lt;p&gt;The dashboard plays the same role. It is not there to look nice, even if I eventually gave it a decent shape because I am weak in the face of a clean interface. Its real job is letting me check that an agent ran, what it saw, what it did, and sometimes what it refused to do. Dates are absolute rather than relative, because “two hours ago” turns false fast in a cached page or a screenshot. Timestamps come from the system, not from the model’s imagination. An agent that writes a future date is not visionary, just broken.&lt;/p&gt;

&lt;h2 id=&quot;what-this-really-shows&quot;&gt;What this really shows&lt;/h2&gt;

&lt;p&gt;Hermes is a side project, but not a technical whim. I treat my own business as an internal product. The need was never to use AI. The need was to reduce forgotten things, prioritize work that actually matters, keep a usable financial picture, avoid repetitive admin, make client context available at the right moment, and preserve human decisions where they carry value.&lt;/p&gt;

&lt;p&gt;That logic looks a lot like the one I apply at client sites. Before building anything, you have to understand the real work: who does what, with which information, in what order, with which risks, and which decisions must absolutely not be automated. Only then do you pick the tool. Sometimes that means a full custom application. Sometimes it means automating three painful steps. Sometimes it mostly means asking Excel to stop playing ERP, CRM, scheduler, reporting layer and collective conscience of the company all at once. Excel is very good, but it too deserves a dignified retirement.&lt;/p&gt;

&lt;p&gt;I am not going to publish Hermes as it stands. It contains my professional life, my clients, my finances, my emails and enough internal paths to make any normally constituted CISO cough. The principles, though, are reusable. Start from a recurring friction, separate information from action from decision, give every automation a narrow scope, keep state inspectable, put the human in the loop in the right places, and use an LLM for ambiguity rather than as a replacement for a reliable script.&lt;/p&gt;

&lt;h2 id=&quot;if-any-of-this-sounds-familiar&quot;&gt;If any of this sounds familiar&lt;/h2&gt;

&lt;p&gt;Hermes is personal, but the problem is very common: scattered information, manual processes, forgotten follow-ups, decisions made without the full picture, SaaS tools that are too generic, and an Excel file still standing out of sheer local patriotism. In those situations the right question is rarely “how do we put AI in the company?”. It runs more like this: which tasks keep coming back, which decisions genuinely need a human, which information arrives too late, and which existing tools could be connected instead of replaced.&lt;/p&gt;

&lt;p&gt;That is the kind of work I do at &lt;a href=&quot;/en/contact/?ref=hermes-agent&quot;&gt;SXN Labs&lt;/a&gt;, understanding the business, finding the real friction, building the software or the automations that cover the need, then leaving behind a system that is simple to use, observable and maintainable. Not a magic demo. A tool that helps, with enough limits to stay good company.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="ai" />
    <category term="AI" />
    <category term="Automation" />
    <category term="Product" />
    <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="en">
    <title type="html">Connecting INSi with strict TLS and clinical continuity</title>
    <link href="https://sxnlabs.com/en/ruby/2026/06/04/racine-igc-sante-tls-insi/" rel="alternate" type="text/html" title="Connecting INSi with strict TLS and clinical continuity" />
    <published>2026-06-04T18:00:00+02:00</published>
    <updated>2026-06-04T18:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/ruby/2026/06/04/racine-igc-sante-tls-insi/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/ruby/2026/06/04/racine-igc-sante-tls-insi/">&lt;p&gt;One Tuesday morning, every call from a production healthcare app that I build and maintain to the French national INSi teleservice started failing server-side. Patient identity verification was completely broken.&lt;/p&gt;

&lt;p&gt;On the user side, the software did what it was supposed to do: it did not show a stacktrace, it indicated that INS verification had not completed. The patient was there, the care flow could continue, but the identity remained unverified.&lt;/p&gt;

&lt;p&gt;The underlying error, captured in Sentry, was a single line:&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;Diagnosis took twenty minutes. The clean fix, two hours. But the point was not to rework the UX or the business behavior. The INSi integration was already running in production with strict TLS verification and correct behavior on failure; the incident mostly exposed one operational subject that had to be made explicit: the IGC-Santé trust chain.&lt;/p&gt;

&lt;p&gt;The service had to work again, without weakening TLS, while preserving an important property: an external failure should not block clinical work.&lt;/p&gt;

&lt;p&gt;There is a wrong answer to this problem, copy-pasted everywhere on forums, that you absolutely must avoid when you ship patient identity data over the wire. It fixes TLS about as well as removing the engine light fixes a car.&lt;/p&gt;

&lt;h2 id=&quot;the-actual-need&quot;&gt;The Actual Need&lt;/h2&gt;

&lt;p&gt;The business need was not “complete a TLS handshake”. It was simpler:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Verify a patient’s INS identity when the teleservice responds.&lt;/li&gt;
  &lt;li&gt;Let the clinician keep working when the teleservice does not respond.&lt;/li&gt;
  &lt;li&gt;Clearly mark the identity data as provisional until it has been verified.&lt;/li&gt;
  &lt;li&gt;Know before the next outage that a certificate is about to expire.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TLS was only one part of the problem. Important, but not enough. A critical integration also needs an explicit answer for the days when the external dependency does not respond.&lt;/p&gt;

&lt;h2 id=&quot;what-insi-does&quot;&gt;What INSi Does&lt;/h2&gt;

&lt;p&gt;INSi is the CNAM (French health insurance) teleservice that lets a clinical app fetch or verify a patient’s &lt;strong&gt;National Health Identifier&lt;/strong&gt; from civil traits: name, first name, date and place of birth. It is a central building block for producing, exchanging, and archiving healthcare data against the right patient identity.&lt;/p&gt;

&lt;p&gt;The endpoint lives on &lt;code class=&quot;highlighter-rouge&quot;&gt;services-ps-tlsm.ameli.fr&lt;/code&gt;. It’s SOAP, authenticated by a client certificate issued through the ANS Portail de Confiance. On the server side, the TLS chain roots in the &lt;strong&gt;IGC-Santé root CA&lt;/strong&gt;, the French state authority for healthcare PKI.&lt;/p&gt;

&lt;p&gt;That root is in &lt;strong&gt;no default system trust store&lt;/strong&gt;. Not Debian, not Ubuntu, not macOS, not the Mozilla bundle embedded in most HTTP libraries. It’s a specialized government PKI, separate from the public web PKI. If you do not add it explicitly, OpenSSL will not discover it through administrative telepathy.&lt;/p&gt;

&lt;h2 id=&quot;the-bad-fix&quot;&gt;The Bad Fix&lt;/h2&gt;

&lt;p&gt;If you search “OpenSSL self-signed certificate in certificate chain Ruby”, you’ll find ten StackOverflow answers all saying the same thing:&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;# DO NOT do this on a patient identity flow&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;On a personal script scraping a site with a self-signed certificate, that is between you and your conscience. On a flow that ships a patient’s social security number, name, and date of birth to a service authenticated by client certificate? &lt;strong&gt;Absolutely not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Disabling &lt;code class=&quot;highlighter-rouge&quot;&gt;verify_mode&lt;/code&gt; means:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Accepting any certificate on the other side, including a MitM attacker on the network.&lt;/li&gt;
  &lt;li&gt;Punching a hole in the only cryptographic guarantee that the machine you’re talking to is in fact CNAM’s.&lt;/li&gt;
  &lt;li&gt;Carving into the codebase, durably, a security regression no one will ever revisit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And for nothing, since the real answer is quick, documentable, and keeps security in place. A useful detail when the payload is patient identity.&lt;/p&gt;

&lt;h2 id=&quot;the-clean-fix&quot;&gt;The Clean Fix&lt;/h2&gt;

&lt;p&gt;ANS publishes the IGC-Santé chain publicly on its site (root + intermediates, PEM format). The right reflex:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Download the official chain&lt;/strong&gt; from the ANS portal.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Verify fingerprints&lt;/strong&gt;: SHA-256 of the downloaded cert compared against the one served by the live endpoint, and against the one published by ANS. Three sources, one hash. If any of the three disagrees, stop.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Bundle it into the project&lt;/strong&gt;, under version control, distinct from client certificates (which stay in encrypted credentials).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Point OpenSSL at it&lt;/strong&gt; when building the SOAP client.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In a Rails app, simplified, this is what &lt;code class=&quot;highlighter-rouge&quot;&gt;Ameli::Insi&lt;/code&gt; ends up looking like:&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;# bundled public chain&lt;/span&gt;
      &lt;span class=&quot;ss&quot;&gt;ssl_verify_mode: :peer&lt;/span&gt;                   &lt;span class=&quot;c1&quot;&gt;# strict verification stays on&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;To validate end-to-end before even redeploying, &lt;code class=&quot;highlighter-rouge&quot;&gt;openssl&lt;/code&gt; does the job:&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;Expected output: &lt;code class=&quot;highlighter-rouge&quot;&gt;Verify return code: 0 (ok)&lt;/code&gt;. From there, the Ruby client works again, in &lt;code class=&quot;highlighter-rouge&quot;&gt;VERIFY_PEER&lt;/code&gt;, with no security downgrade.&lt;/p&gt;

&lt;h2 id=&quot;the-useful-behavior&quot;&gt;The Useful Behavior&lt;/h2&gt;

&lt;p&gt;Once the root cause was fixed, one interesting point was worth making explicit: why had the incident not blocked the clinician?&lt;/p&gt;

&lt;p&gt;Because the software did not treat an INSi failure as an exception to throw on screen, but as a business state. When INSi answered, patient identity was verified. When the teleservice did not answer, the technical error stayed in Sentry and the interface told the clinician that INS verification could not be completed.&lt;/p&gt;

&lt;p&gt;The application boundary looks like this, simplified:&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;The calling controller rescues &lt;code class=&quot;highlighter-rouge&quot;&gt;TeleserviceUnavailable&lt;/code&gt; and keeps a &lt;strong&gt;provisional identity&lt;/strong&gt; (the civil traits typed by the clinician, tagged “INS not verified”), with a banner explaining INSi is down and that the record must be re-verified later. The clinician keeps working, the patient isn’t blocked, and the data will be reconciled at the next successful call.&lt;/p&gt;

&lt;p&gt;That was already the useful behavior. Not spectacular, not great demo material, but it is what matters in production: an external dependency going down should not stop the care chain. It should become an explicit degraded mode.&lt;/p&gt;

&lt;h2 id=&quot;the-invisible-work&quot;&gt;The Invisible Work&lt;/h2&gt;

&lt;p&gt;This incident also brought back an operational point that is easy to underestimate. INSi, MSSanté, and Pro Santé Connect client certificates are renewed manually on the ANS Portail de Confiance, then stored in encrypted credentials. The app can work perfectly for months, until one deadline arrives silently.&lt;/p&gt;

&lt;p&gt;The day one expires, you get the same outage again, with a familiar smell and a perfectly reasonable urge to insult a calendar.&lt;/p&gt;

&lt;p&gt;So, right after the fix, I added a daily job that walks the inventory of bundled certificates: public chains and client certificates. It reports to Sentry any certificate within 30 days of expiry, then escalates at 7 days or once a certificate has already expired.&lt;/p&gt;

&lt;p&gt;I also added a renewal runbook versioned in the repo (&lt;code class=&quot;highlighter-rouge&quot;&gt;lib/certs/README.md&lt;/code&gt;), with the PFC procedure step by step. No one will see it as long as it works. When it threatens to break, it will be an actionable warning, not a surprise outage.&lt;/p&gt;

&lt;h2 id=&quot;a-quick-map-of-french-healthcare-pki&quot;&gt;A quick map of French healthcare PKI&lt;/h2&gt;

&lt;p&gt;For anyone discovering the ecosystem, the building blocks look alike and it’s easy to get lost:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;INS&lt;/strong&gt;: the patient’s national health identifier (NIR or NIA + traits). The data.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;INSi&lt;/strong&gt;: the CNAM teleservice that lets you fetch / verify an INS. The API.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;MSSanté&lt;/strong&gt;: secure messaging between professionals and with the patient. Separate PKI, certificates issued by MSSanté operators.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Pro Santé Connect&lt;/strong&gt;: the state SSO for healthcare professionals (dematerialized CPS card, OIDC).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;IGC-Santé&lt;/strong&gt;: the shared PKI root that signs most of the ANS server side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each block is documented, each chain is public and verifiable. The trap is treating this as an annoying technical detail when it is part of the product: identity, security, continuity of care, and operations.&lt;/p&gt;

&lt;h2 id=&quot;what-i-keep-from-this&quot;&gt;What I Keep From This&lt;/h2&gt;

&lt;p&gt;When a TLS integration breaks in production, the right answer is almost never “disable verification”. It’s: &lt;strong&gt;find the missing trust anchor&lt;/strong&gt;, make it explicit, version it, audit it over time. The &lt;code class=&quot;highlighter-rouge&quot;&gt;VERIFY_NONE&lt;/code&gt; reflex saves ten minutes now and leaves a security debt to age quietly in the codebase.&lt;/p&gt;

&lt;p&gt;The other point, more product than TLS, is that any critical integration needs a degraded mode. An external teleservice going down is normal over a decade of operations. Here, that decision already existed on the user side: INS verification failed, the clinician was informed, and care could continue.&lt;/p&gt;

&lt;p&gt;The real work was the whole loop: understand the clinical need, make the trust chain explicit, preserve security, rely on the existing business fallback, then monitor certificates so we do not replay the same scene three months later in a different outfit.&lt;/p&gt;

&lt;h2 id=&quot;if-you-have-one-of-these-at-work&quot;&gt;If you have one of these at work&lt;/h2&gt;

&lt;p&gt;French healthcare integrations (INSi, MSSanté, Pro Santé Connect, DMP) are a mix of PKI, certificates, WSDL files, and ANS procedures that require more patience than genius. They work. But you need to frame the business need as much as the technical connection.&lt;/p&gt;

&lt;p&gt;This is the kind of work I handle at &lt;a href=&quot;/contact/?ref=insi-tls&quot;&gt;SXN Labs&lt;/a&gt;: understand what actually needs to keep working, simplify the scope, connect it properly, and leave behind a system people can operate. If you have a healthcare teleservice stuck somewhere, drop me a line.&lt;/p&gt;</content>
    <author>
      <name>Nathan Le Ray</name>
      <uri>https://www.linkedin.com/in/nathanleray/</uri>
    </author>
    <category term="ruby" />
    <category term="Healthcare" />
    <category term="TLS" />
    <category term="PKI" />
    <category term="Rails" />
    <category term="INSi" />
    <category term="Lessons learned" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2026-06-04-racine-igc-sante-tls-insi.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">Controlling my Intex spa without its app</title>
    <link href="https://sxnlabs.com/en/side-project/2026/05/31/piloter-mon-spa-intex-sans-leur-app/" rel="alternate" type="text/html" title="Controlling my Intex spa without its app" />
    <published>2026-05-31T18:00:00+02:00</published>
    <updated>2026-05-31T18:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/side-project/2026/05/31/piloter-mon-spa-intex-sans-leur-app/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/side-project/2026/05/31/piloter-mon-spa-intex-sans-leur-app/">&lt;p&gt;I have an Intex PureSpa Baltik inflatable spa in the garden. It works great. The iOS app that comes with it, on the other hand, is a disaster: you need a Tuya cloud account, auth drops every three days, half the commands take 10 seconds to land, and the UI looks like a POC abandoned in 2018. For a device that lives on my Wi-Fi 3 meters from my Mac, the round trip through a server in China felt pointlessly absurd.&lt;/p&gt;

&lt;p&gt;So I dumped the app and wrote my own. One weekend of reverse engineering, then a few evenings stacking the features the official app will never ship. Here’s how it turned out.&lt;/p&gt;

&lt;h2 id=&quot;reverse-engineering-in-30-minutes&quot;&gt;Reverse engineering in 30 minutes&lt;/h2&gt;

&lt;p&gt;I got insanely lucky: &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; had already done the thankless work. The spa’s Wi-Fi module listens on TCP port 8990, the protocol is binary with a simple checksum (modulo 0xFF, not 0x100, that’s the classic trap), and the functional commands (power, heat, filtration, bubbles) are &lt;strong&gt;toggles&lt;/strong&gt;: you read the current state and only send if the desired state differs. Idempotent by construction.&lt;/p&gt;

&lt;p&gt;I validated it byte-for-byte against my actual spa with a standalone &lt;code class=&quot;highlighter-rouge&quot;&gt;probe.py&lt;/code&gt; (stdlib only, zero deps), then packaged that into a pure &lt;code class=&quot;highlighter-rouge&quot;&gt;protocol.py&lt;/code&gt; layer that tests offline.&lt;/p&gt;

&lt;p&gt;The whole architecture follows from these invariants:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;One TCP connection only&lt;/strong&gt;. The firmware accepts a single client at a time on 8990. Everything goes through one &lt;code class=&quot;highlighter-rouge&quot;&gt;IntexSpaClient&lt;/code&gt; with an asyncio lock, owned by one &lt;code class=&quot;highlighter-rouge&quot;&gt;Supervisor&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Polling doubles as keepalive&lt;/strong&gt;. The firmware closes the socket if nothing talks for too long. So we poll every 10 s, and that also feeds the UI over SSE.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Stale-but-useful&lt;/strong&gt;. If the spa becomes unreachable, we keep the last known reading and show an “offline” banner. The next poll catches everything up.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-the-official-app-will-never-do&quot;&gt;What the official app will never do&lt;/h2&gt;

&lt;h3 id=&quot;a-real-web-ui&quot;&gt;A real web UI&lt;/h3&gt;

&lt;p&gt;FastAPI + HTMX + Chart.js (vendored, no CDN, the app lives on the LAN, it has to work if the internet drops). A mobile-first page, a 7-day temperature chart, instant controls. All served by a single uvicorn process on my Mac running 24/7.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/spa/desktop-overview.png&quot; alt=&quot;Desktop dashboard, temperature gauge, toggles, 7-day chart, camera, weather, scheduler&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;a-weather-aware-scheduler&quot;&gt;A weather-aware scheduler&lt;/h3&gt;

&lt;p&gt;The Intex app offers a rudimentary timer that isn’t even exposed over the LAN protocol, so I wrote my own. Three primitives: setpoint by time slot, filtration windows, and most importantly &lt;strong&gt;“ready by 6 PM”&lt;/strong&gt; which figures out when to start heating.&lt;/p&gt;

&lt;p&gt;The interesting twist is that the heating rate isn’t constant. A spa loses heat proportionally to &lt;code class=&quot;highlighter-rouge&quot;&gt;(water − outdoor air)&lt;/code&gt;. So I pull local weather from Open-Meteo (free, no API key), learn the thermal loss coefficient from the history of heating and cooling phases, and the scheduler automatically shifts the start time earlier when it’s cold. If the night will be 4 °C, we start heating 90 min ahead; if it’s 18 °C, 30 min is enough.&lt;/p&gt;

&lt;h3 id=&quot;a-camera-with-cover-detection-experimental&quot;&gt;A camera with cover detection (experimental)&lt;/h3&gt;

&lt;p&gt;The spa is partly visible in the field of an outdoor IP camera. I plugged in ffmpeg to grab a frame every 10 s, write it atomically (&lt;code class=&quot;highlighter-rouge&quot;&gt;tmp + replace&lt;/code&gt;), rebuild a daily mp4 timelapse on the fly, and (for kicks) detect whether the cover is on via a calibratable ROI and a luma + std-dev heuristic. It’s partial and flaky at night, so it isn’t wired into the scheduler in v1. But the plumbing is there for the day it is.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/spa/mobile-camera-settings.png&quot; alt=&quot;Camera card with settings panel, mobile&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;a-launchd-service-that-survives-silent-failures&quot;&gt;A launchd service that survives silent failures&lt;/h3&gt;

&lt;p&gt;This is the part that took me the longest and that nobody talks about. On this machine, &lt;strong&gt;CPython 3.14 was silently killing the service after ~30 s&lt;/strong&gt; under launchd. No traceback, no log, just a dying process. The culprit is a segfault in &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; on long loops. Back to 3.12, problem gone. And then all the plist settings you only learn the hard way: &lt;code class=&quot;highlighter-rouge&quot;&gt;ThrottleInterval=15&lt;/code&gt; to avoid respawn loops that saturate launchd, &lt;code class=&quot;highlighter-rouge&quot;&gt;ProcessType=Adaptive&lt;/code&gt; (not &lt;code class=&quot;highlighter-rouge&quot;&gt;Background&lt;/code&gt;, otherwise jetsam kills us first under memory pressure), &lt;code class=&quot;highlighter-rouge&quot;&gt;ExitTimeOut=20&lt;/code&gt; to let the TCP connection close cleanly, and crucially &lt;strong&gt;no &lt;code class=&quot;highlighter-rouge&quot;&gt;--workers 1&lt;/code&gt;&lt;/strong&gt; on uvicorn (that flag flips it into multiprocess mode and wedges under launchd; the default single-process does exactly what you want).&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 for remote access. No database. The whole state fits in a handful of JSON and JSONL files under &lt;code class=&quot;highlighter-rouge&quot;&gt;state/&lt;/code&gt;. 135 offline tests that run in 3 seconds without touching the real spa (a &lt;code class=&quot;highlighter-rouge&quot;&gt;fake_spa.py&lt;/code&gt; replays the protocol).&lt;/p&gt;

&lt;h2 id=&quot;the-reusable-pattern&quot;&gt;The reusable pattern&lt;/h2&gt;

&lt;p&gt;This black box looks like every other one: a low-end IoT device that demands a cloud account to do the most trivial job. The pattern works for Tuya bulbs, Somfy shutters, and probably half the things in your house too:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Find the open TCP port on the LAN (an &lt;code class=&quot;highlighter-rouge&quot;&gt;nmap&lt;/code&gt; usually does it).&lt;/li&gt;
  &lt;li&gt;Capture a few exchanges with Wireshark from the official app.&lt;/li&gt;
  &lt;li&gt;Identify the checksum and framing (protocol docs are rarely public, but rarely complicated either).&lt;/li&gt;
  &lt;li&gt;Rewrite a minimal client, validate byte-for-byte, add what the vendor never wanted to build.&lt;/li&gt;
  &lt;li&gt;Block the device’s WAN egress at the router, end of phone-home.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code is public, now split across two repositories: &lt;a href=&quot;https://github.com/sxnlabs/onsen-server&quot;&gt;github.com/sxnlabs/onsen-server&lt;/a&gt; for the FastAPI service and spa protocol, and &lt;a href=&quot;https://github.com/sxnlabs/onsen-app&quot;&gt;github.com/sxnlabs/onsen-app&lt;/a&gt; for the application. Permissive licenses. If you have a PureSpa Baltik, you can literally fork the server and install it. And if you have another device with the same pattern, read &lt;code class=&quot;highlighter-rouge&quot;&gt;intex_spa/protocol.py&lt;/code&gt; on the server side, it’s a good template for starting your own.&lt;/p&gt;

&lt;h2 id=&quot;if-you-have-one-of-these-at-work&quot;&gt;If you have one of these at work&lt;/h2&gt;

&lt;p&gt;I run into this black-box situation regularly on the consulting side: an industrial machine with an undocumented API, a sensor stuck behind a cloud app that bottlenecks the whole team, a B2B home-automation integration the vendor won’t build. That’s the kind of thing I unblock at &lt;a href=&quot;/contact/?ref=spa-intex&quot;&gt;SXN Labs&lt;/a&gt;. If you have a device that should just talk to your stack, drop me a line.&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.en.png" />
  </entry>
</feed>

