<?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-08-07T20:56:03+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://sxnlabs.com/</uri>
  </author>
  <entry xml:lang="en">
    <title type="html">Turbo doesn&#39;t have three tools, it has three scopes</title>
    <link href="https://sxnlabs.com/en/ruby/2026/08/06/turbo-drive-frames-streams-reference/" rel="alternate" type="text/html" title="Turbo doesn&#39;t have three tools, it has three scopes" />
    <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 only question that matters when you sit down to write: which one, here, now.&lt;/p&gt;

&lt;p&gt;There is an answer, and it is simple. Turbo does exactly one thing: it intercepts a navigation, fetches HTML, and replaces a portion of the document. Drive, Frames and Streams are not three mechanisms, they are three &lt;strong&gt;scopes&lt;/strong&gt; of that replacement: the whole page, a named fragment, or any set of elements. And each scope answers the question that governs everything else: who decides the target, the client or the server.&lt;/p&gt;

&lt;p&gt;What follows is what I wish I had read three years ago. It is long, it is meant to be reread in pieces, and it ends with an index that starts from the observable symptom. The reference versions are &lt;strong&gt;Turbo 8.0.23&lt;/strong&gt; and &lt;strong&gt;turbo-rails 2.0.23&lt;/strong&gt;, both released on January 29, 2026, on Rails 8.&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;#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;#the-decision-tree&quot;&gt;The decision tree&lt;/a&gt;&lt;span&gt;which tool for which need&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 item 11 and 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;880&quot; height=&quot;494&quot; src=&quot;/images/posts/turbo/en/01-trois-portees.svg&quot; alt=&quot;Three panels comparing Turbo Drive, which replaces the whole page, Turbo Frames, which replaces a fragment designated by the client, and Turbo Streams, which applies commands to targets designated by the server.&quot; /&gt;
  &lt;figcaption&gt;The same mechanism, three scopes. What changes from one column to the next: the extent of the replacement, and who designates its target.&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;. 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 before the request even leaves. The emitting frame puts its identifier in a &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo-Frame&lt;/code&gt; header, and Turbo will accept from the response only the element carrying that same identifier. The server chooses nothing: it is subject to a constraint.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;Streams&lt;/strong&gt;, the target is written into the response. The server 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 places, not contiguous, and it can do so without anyone having asked for anything, over a WebSocket.&lt;/p&gt;

&lt;p&gt;Everything else follows from that. A frame that does not update is almost always an identifier that does not match. A stream that has no effect is almost always a target missing from the DOM. Morphing, which we will get to, does not change this model: it only changes &lt;em&gt;how&lt;/em&gt; the replacement is applied, not &lt;em&gt;who&lt;/em&gt; decides it.&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 to benefit from it and, contrary to what the order of the documentation’s chapters suggests, that is where the majority of needs should stop.&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 clicks on same-origin &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; elements and form submissions. It &lt;strong&gt;does not intercept&lt;/strong&gt;: links with a &lt;code class=&quot;highlighter-rouge&quot;&gt;target&lt;/code&gt; attribute other than &lt;code class=&quot;highlighter-rouge&quot;&gt;_self&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;download&lt;/code&gt; links, cross-origin URLs, and URLs whose extension appears 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;). That last list is configurable and is documented nowhere on the official site.&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;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;1000&quot; height=&quot;742&quot; src=&quot;/images/posts/turbo/en/02-cycle-drive.svg&quot; alt=&quot;Vertical sequence of the events of a Drive visit, from turbo:click to turbo:load, with the preview from the cache and the body replacement step.&quot; /&gt;
  &lt;figcaption&gt;The dashed boxes are not events: they are the internal steps the events bracket.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Two points are worth stopping on, because they explain a good half of all lifecycle bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The snapshot is taken of 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, right before Turbo makes a &lt;code class=&quot;highlighter-rouge&quot;&gt;cloneNode(true)&lt;/code&gt; of it. So this is your last chance to undo what your JavaScript added to the DOM. And there is a cruel asymmetry: the clone &lt;strong&gt;loses the listeners&lt;/strong&gt; but &lt;strong&gt;keeps the injected DOM&lt;/strong&gt;. That is exactly the recipe for the library initialized twice on back navigation, which we will get to.&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 fires &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:render&lt;/code&gt; twice.&lt;/strong&gt; If the target URL is in the cache, Turbo displays the snapshot immediately while the network request runs. During that preview, &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. That is how you test for it in a Stimulus controller:&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, on reload, and it is emptied in full on &lt;strong&gt;every&lt;/strong&gt; unsafe form submission. It contains only HTML: no stylesheet, no image, no script.&lt;/p&gt;

&lt;p&gt;Remember the phrasing: &lt;strong&gt;Turbo’s cache is a perceived-latency optimization, it is not a persistence layer.&lt;/strong&gt; It gives you strictly nothing offline.&lt;/p&gt;

&lt;p&gt;Three levers:&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;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;cm&quot;&gt;/* Promise&amp;lt;boolean&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;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;forms.submitter&lt;/code&gt; deserves a word, because it is a case where Turbo’s default is bad for accessibility. By default, Turbo sets the &lt;code class=&quot;highlighter-rouge&quot;&gt;disabled&lt;/code&gt; attribute on the button during submission. A &lt;code class=&quot;highlighter-rouge&quot;&gt;disabled&lt;/code&gt; button stays exposed in the accessibility tree, but it loses its focusability and its place in the tab order: focus falls back to &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, and someone navigating by keyboard or screen reader loses their position on every form submission. 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 cancels &lt;code class=&quot;highlighter-rouge&quot;&gt;click&lt;/code&gt; events on the button: the protection against double submission is identical, but the control stays focusable.&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;One line, in &lt;code class=&quot;highlighter-rouge&quot;&gt;application.js&lt;/code&gt;. There is no reason not to write it.&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;. 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.&lt;/p&gt;

&lt;p&gt;That means moving the mouse over a link fires a request against 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 is a different mechanism, a different cache, and a load on &lt;code class=&quot;highlighter-rouge&quot;&gt;DOMContentLoaded&lt;/code&gt; rather than on hover.&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;880&quot; height=&quot;584&quot; src=&quot;/images/posts/turbo/en/03-contrat-frame.svg&quot; alt=&quot;A frame sends the Turbo-Frame header, the server responds with a full page, Turbo extracts only the turbo-frame with the same id, and the rest is discarded. Below, the frame-missing sequence.&quot; /&gt;
  &lt;figcaption&gt;Turbo does exactly one thing with the response: it looks for a &lt;code&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; with the same id. Everything else is discarded.&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. That is why the most robust pattern is to let the server echo back the identifier it was sent, through 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 silently breaks every later navigation to that frame.&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 recurse~=&quot;my-id&quot;&amp;gt;&lt;/code&gt; is present, Turbo waits for it to load and then looks inside it.&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;The most frequent case is session expiry: 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. The right answer is not to handle the event, it is to 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 extracts the frame anyway, but you pay for the entire layout on every frame request, and all the JavaScript injected into the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; is re-evaluated. 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;p&gt;No error, no warning. Just a silent bill.&lt;/p&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. Passed 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;Loads only on entry into the viewport, via an &lt;code class=&quot;highlighter-rouge&quot;&gt;IntersectionObserver&lt;/code&gt;.&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 a successful render. Careful: the JS property &lt;code class=&quot;highlighter-rouge&quot;&gt;frame.complete&lt;/code&gt; 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 in-flight request and ignores any navigation.&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;Scrolls to the frame after rendering. &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;, not &lt;code class=&quot;highlighter-rouge&quot;&gt;start&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;With &lt;code class=&quot;highlighter-rouge&quot;&gt;src&lt;/code&gt;, the frame is reloaded rather than morphed during a page morph.&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Two behaviors that often surprise people:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A frame renders 4xx and 5xx exactly like 2xx.&lt;/strong&gt; The status code is not consulted at all. Only two things can divert the response: a non-HTML &lt;code class=&quot;highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt;, in which case &lt;code class=&quot;highlighter-rouge&quot;&gt;loadResponse&lt;/code&gt; does nothing at all, with no event and no error, and the &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo-visit-control: reload&lt;/code&gt; seen above, which triggers a full-page visit with a console warning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A form inside a frame does not need to redirect.&lt;/strong&gt; The “form responses must redirect” constraint only applies to full-page submissions. A &lt;code class=&quot;highlighter-rouge&quot;&gt;200 OK&lt;/code&gt; with HTML is perfectly valid inside a frame.&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 is the number one source of confusion.&lt;/p&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;, the tab 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.&lt;/p&gt;

&lt;p&gt;The second is a &lt;strong&gt;broadcast&lt;/strong&gt;: a model publishes on an Action Cable channel, every subscribed tab receives it. It is asynchronous, it runs in a job, there is &lt;strong&gt;no request, no session, no &lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt;&lt;/strong&gt;. We come back to it below, because that is where the real problems hide.&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 comes down to two points, and they are decisive: the server returns HTML instead of returning JavaScript to evaluate, and the mutation vocabulary is closed at eight actions instead of being “everything jQuery knows how to do”. We lost freedom and gained a zero attack surface, an HTTP cache that works, and responses you can read.&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;Here is the canonical server-side form, the one you write in 90% of cases.&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;Two ways to respond, and you should choose knowingly. The bare &lt;code class=&quot;highlighter-rouge&quot;&gt;format.turbo_stream&lt;/code&gt; renders a template, which is the right choice as soon as several zones change. The inline &lt;code class=&quot;highlighter-rouge&quot;&gt;render turbo_stream:&lt;/code&gt; renders a single action and saves a file, which is the right choice when there really is only one.&lt;/p&gt;

&lt;p&gt;The template itself is a plain ERB file that yields the 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;Three remarks on those three lines, because they condense almost the whole API.&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 which nothing will be able to target it afterwards.&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 it. Handy, but it is also the one place where it is easy to forget that the content is rendered &lt;strong&gt;outside the page context&lt;/strong&gt;, so without the ivars set by some other &lt;code class=&quot;highlighter-rouge&quot;&gt;before_action&lt;/code&gt;.&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;p&gt;This is the most widespread mistake in the whole ecosystem, including in widely read blog posts.&lt;/p&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;Triggers a page refresh. 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; ignores it purely and simply. &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;Two barely documented behaviors:&lt;/p&gt;

&lt;p&gt;&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, which is very handy and never stated. 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; do the same thing on the target’s siblings.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;refresh&lt;/code&gt; can be deduplicated through a &lt;code class=&quot;highlighter-rouge&quot;&gt;request-id&lt;/code&gt; attribute, the subject of the broadcasts section.&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;So the trap only shows up the day 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;This is the most expensive behavior in all of Turbo in debugging time.&lt;/p&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;p&gt;There is a second silent variant, 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, your &lt;code class=&quot;highlighter-rouge&quot;&gt;respond_to&lt;/code&gt; will never see the &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream&lt;/code&gt; format and will fall into the HTML branch.&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;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;940&quot; height=&quot;664&quot; src=&quot;/images/posts/turbo/en/04-reponse.svg&quot; alt=&quot;Decision tree: turbo-stream content-type, then frame request, then HTTP status, with the four outcomes 200, 4xx, 5xx and 3xx.&quot; /&gt;
  &lt;figcaption&gt;Three questions, in this order. The HTTP status is consulted last, and only for a full-page navigation.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The number one symptom in all of Hotwire 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;If you answer a POST with &lt;code class=&quot;highlighter-rouge&quot;&gt;200&lt;/code&gt; and 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.&lt;/p&gt;

&lt;p&gt;The reason is given in the manual, and it is a good one: browsers have native behavior 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 a consequence of the Fetch specification. Turbo passes &lt;code class=&quot;highlighter-rouge&quot;&gt;redirect: &quot;follow&quot;&lt;/code&gt; and lets the browser follow. And:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;303&lt;/strong&gt; always forces the switch to &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 GET &lt;strong&gt;only for POST&lt;/strong&gt;. A &lt;code class=&quot;highlighter-rouge&quot;&gt;DELETE&lt;/code&gt; redirected with a 302 is &lt;strong&gt;replayed as a &lt;code class=&quot;highlighter-rouge&quot;&gt;DELETE&lt;/code&gt;&lt;/strong&gt; on the target URL.&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;So a &lt;code class=&quot;highlighter-rouge&quot;&gt;redirect_to invoices_path&lt;/code&gt; after a &lt;code class=&quot;highlighter-rouge&quot;&gt;destroy&lt;/code&gt; without &lt;code class=&quot;highlighter-rouge&quot;&gt;status: :see_other&lt;/code&gt; sends a &lt;code class=&quot;highlighter-rouge&quot;&gt;DELETE /invoices&lt;/code&gt; to your server. At best a routing error, at worst something you had not planned for.&lt;/p&gt;

&lt;p&gt;One nuance that makes life easier: &lt;strong&gt;a turbo-stream response short-circuits all of this&lt;/strong&gt;. Turbo intercepts on the &lt;code class=&quot;highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt; before it even looks at the status. &lt;code class=&quot;highlighter-rouge&quot;&gt;render turbo_stream: …, status: :unprocessable_entity&lt;/code&gt; works perfectly, and the 422 now only serves your 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; goes straight through the guard and carries on to a visit.&lt;/p&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. You keep the scroll, the focus, the text selection, the state of CSS transitions.&lt;/p&gt;

&lt;p&gt;You turn it on in two lines, 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 morph only happens for a &lt;strong&gt;page refresh&lt;/strong&gt;, and the exact condition 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;.&lt;/p&gt;

&lt;p&gt;In practice, for a form, that amounts to “same URL”, because Turbo only picks &lt;code class=&quot;highlighter-rouge&quot;&gt;replace&lt;/code&gt; if the destination URL is strictly identical to the starting URL. A &lt;code class=&quot;highlighter-rouge&quot;&gt;POST /invoices/42&lt;/code&gt; that redirects to &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices/42&lt;/code&gt; morphs. A &lt;code class=&quot;highlighter-rouge&quot;&gt;POST /invoices/42/edit&lt;/code&gt; that redirects to &lt;code class=&quot;highlighter-rouge&quot;&gt;/invoices/42&lt;/code&gt; does not morph: two different paths, classic rendering.&lt;/p&gt;

&lt;p&gt;But the &lt;code class=&quot;highlighter-rouge&quot;&gt;action&lt;/code&gt; clause counts for the rest. 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; morphs too, since the &lt;code class=&quot;highlighter-rouge&quot;&gt;pathname&lt;/code&gt; is the same.&lt;/p&gt;

&lt;p&gt;The two most common triggers: a form that redirects to the same URL, 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-removes&quot;&gt;What morphing removes&lt;/h3&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;880&quot; height=&quot;676&quot; src=&quot;/images/posts/turbo/en/05-morph.svg&quot; alt=&quot;Comparison of the two render pipelines: classic, with before-cache, body replacement and script re-execution, against morph, where before-cache no longer happens and nothing is re-executed.&quot; /&gt;
  &lt;figcaption&gt;Same beginning, same end. It is the middle that changes, and the middle is where your code lives.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Three corrections to some very widespread folklore:&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; no longer fires.&lt;/strong&gt; The refresh passes &lt;code class=&quot;highlighter-rouge&quot;&gt;shouldCacheSnapshot: false&lt;/code&gt;. And &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; is &lt;em&gt;the&lt;/em&gt; global teardown point recommended everywhere since Turbolinks. Turning morphing on therefore silently disables the cleanup code of half the existing Rails applications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scripts are not “never re-executed”, they are re-executed if they are new.&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 the text content with &lt;code class=&quot;highlighter-rouge&quot;&gt;oldNode.nodeValue = newNode.nodeValue&lt;/code&gt;. Assigning &lt;code class=&quot;highlighter-rouge&quot;&gt;nodeValue&lt;/code&gt; on an already executed script never re-executes it. A genuinely new script, on the other hand, with no partner in the old tree, is inserted and &lt;strong&gt;runs&lt;/strong&gt;.&lt;/p&gt;

&lt;h3 id=&quot;idiomorph-decides-by-identifiers&quot;&gt;idiomorph decides by identifiers&lt;/h3&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; anywhere in the document takes that &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; out of the set of persistent identifiers, &lt;strong&gt;in both copies&lt;/strong&gt;. Matching then falls back to position. So an accidental duplicate in a partial degrades the morphing of elements that have nothing to do with it.&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. Consequences: CSS transitions restart everywhere, and any client state carried by a row (an open menu, a checked box, a playing video) shifts by one.&lt;/p&gt;

&lt;p&gt;The rule is short: &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, never change the associated tag. &lt;code class=&quot;highlighter-rouge&quot;&gt;dom_id(record)&lt;/code&gt; does exactly that.&lt;/p&gt;

&lt;h3 id=&quot;the-pantry-and-why-it-breaks-in-safari&quot;&gt;The pantry, and why it breaks in Safari&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 a node with a persistent identifier has to change place, idiomorph does not clone it. It moves it into a hidden &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;div&amp;gt;&lt;/code&gt; inserted &lt;strong&gt;after &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;/body&amp;gt;&lt;/code&gt;&lt;/strong&gt;, the pantry, then puts it back in position later. The move uses &lt;code class=&quot;highlighter-rouge&quot;&gt;parentNode.moveBefore()&lt;/code&gt; &lt;strong&gt;if the browser provides it&lt;/strong&gt;, otherwise &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is not an implementation detail, it is a fork in behavior:&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&lt;/strong&gt;. No 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;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; elements do not reload, a &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;video&amp;gt;&lt;/code&gt; keeps playing.&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;disconnected then reconnected&lt;/strong&gt;. &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; are called, &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; elements reload, video playback restarts from zero, CSS transitions restart, and the &lt;code class=&quot;highlighter-rouge&quot;&gt;connectedCallback&lt;/code&gt; of custom elements replays.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As of this writing, the &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt; camp has shrunk to Safari and WebKit iOS, plus versions earlier than Chrome 133 and Firefox 144. So yes, a morphing bug can be perfectly reproducible in Safari and nowhere to be found in Chrome. It is not your code.&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;And a server-rendered &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;input type=&quot;text&quot;&amp;gt;&lt;/code&gt; normally has &lt;strong&gt;no&lt;/strong&gt; &lt;code class=&quot;highlighter-rouge&quot;&gt;value&lt;/code&gt; attribute. So a refresh that arrives while someone is typing erases what they are typing. Same treatment for &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;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. &lt;strong&gt;Turbo does not enable it&lt;/strong&gt;, and does not expose it anywhere: you cannot set it.&lt;/p&gt;

&lt;p&gt;So it is on you to protect the field yourself, and the only lever available on the Turbo side is &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt;, which skips the morph on the element. Since you do not want to freeze the field permanently, you set it on focus and remove it on the way out. This snippet comes from Turbo’s own test fixtures, which makes it the most official answer in existence:&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;k&quot;&gt;if &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;HTMLInputElement&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;target&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;target&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;nx&quot;&gt;target&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;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;target&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;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;once&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;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;On focus, more precisely: idiomorph restores focus, but 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;that carries an &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;&lt;/strong&gt;. A focused &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;select&amp;gt;&lt;/code&gt;, a focused &lt;code class=&quot;highlighter-rouge&quot;&gt;contenteditable&lt;/code&gt; or a field without an &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; lose focus, and there is no option to change that.&lt;/p&gt;

&lt;p&gt;And &lt;code class=&quot;highlighter-rouge&quot;&gt;autofocus&lt;/code&gt; is not honored at all under morph: &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;.&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;carte&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;Two clarifications the documentation does not give.&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. Under morph, only the attribute is tested, the &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; is not required, and the node is simply skipped. The &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; becomes necessary again for &lt;strong&gt;added&lt;/strong&gt; nodes, where it serves for deduplication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Under morph, &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; freezes the 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;This is where the mental model needs to be soundest, because the code is written in one line and the problems show up 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;This is intentional: the intended pattern is &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream_from Card&lt;/code&gt; on the index and &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream_from @card&lt;/code&gt; on the detail page. But it is written nowhere in readable form. If you want all three events to go to the same place, the macro to use is &lt;code class=&quot;highlighter-rouge&quot;&gt;broadcasts_refreshes_to&lt;/code&gt;, which installs a single &lt;code class=&quot;highlighter-rouge&quot;&gt;after_commit&lt;/code&gt;.&lt;/p&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 precisely 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;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;880&quot; height=&quot;588&quot; src=&quot;/images/posts/turbo/en/06-broadcast.svg&quot; alt=&quot;Sequence diagram between the writing tab, the server and a watching tab, showing the X-Turbo-Request-Id header, the 0.5 second debounce, and the refresh being dropped in the originating tab.&quot; /&gt;
  &lt;figcaption&gt;The tab that writes receives its own broadcast. The whole identifier mechanism exists to let it ignore that broadcast.&lt;/figcaption&gt;
&lt;/figure&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;. The broadcast re-emits it in the &lt;code class=&quot;highlighter-rouge&quot;&gt;request-id&lt;/code&gt; attribute. 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 a pointless round trip, the scroll position and the focus.&lt;/p&gt;

&lt;p&gt;Four ways to lose this protection, all verifiable in the code:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;broadcast_refresh_to&lt;/code&gt;, the synchronous variant, transmits no &lt;code class=&quot;highlighter-rouge&quot;&gt;request_id&lt;/code&gt;.&lt;/strong&gt; Only the &lt;code class=&quot;highlighter-rouge&quot;&gt;_later_&lt;/code&gt; variant does. It is also the variant the macro installs for &lt;code class=&quot;highlighter-rouge&quot;&gt;destroy&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;From a background job&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; set by an &lt;code class=&quot;highlighter-rouge&quot;&gt;around_action&lt;/code&gt;. Every tab refreshes, including the one that started it. That is generally what you want.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The set is capped at 20.&lt;/strong&gt; More than 20 Turbo requests between the send and the arrival of the broadcast and the identifier is evicted. With prefetch on hover on by default, that is no longer so theoretical.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.refresh&lt;/code&gt; rendered as a direct response&lt;/strong&gt; to a request carries that request’s identifier by default, so the requesting tab ignores it. You have to write &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_stream.refresh(request_id: nil)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&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 cancels the previous one. A thousand records modified in one request therefore give &lt;strong&gt;one&lt;/strong&gt; broadcast.&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;Two less pleasant consequences:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In a short-lived process, the broadcast is never sent.&lt;/strong&gt; A &lt;code class=&quot;highlighter-rouge&quot;&gt;rails runner&lt;/code&gt;, a rake task, a container that exits after its work: the process ends before the scheduled task fires. No error. The documented workaround is a &lt;code class=&quot;highlighter-rouge&quot;&gt;sleep Turbo::Debouncer::DEFAULT_DELAY + 0.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In tests, there is no debounce at all.&lt;/strong&gt; turbo-rails installs a &lt;code class=&quot;highlighter-rouge&quot;&gt;Turbo::ImmediateDebouncer&lt;/code&gt; in the test environment. Your assertions count N broadcasts, production will see 1.&lt;/p&gt;

&lt;h3 id=&quot;current_user-does-not-exist-in-a-broadcast&quot;&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; does not exist in a broadcast&lt;/h3&gt;

&lt;p&gt;Every asynchronous broadcast renders its partial 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;That is, 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;&lt;code class=&quot;highlighter-rouge&quot;&gt;current_user&lt;/code&gt; is &lt;code class=&quot;highlighter-rouge&quot;&gt;nil&lt;/code&gt;.&lt;/strong&gt; Devise returns &lt;code class=&quot;highlighter-rouge&quot;&gt;nil&lt;/code&gt; rather than raising: a partial that hides an admin button behind &lt;code class=&quot;highlighter-rouge&quot;&gt;if current_user.admin?&lt;/code&gt; therefore renders the non-admin branch &lt;strong&gt;for everyone&lt;/strong&gt;, silently, with nothing in the console or in the logs.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;Current.*&lt;/code&gt; attributes are empty&lt;/strong&gt;, for the same reason.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;ApplicationController&lt;/code&gt; is hard-coded.&lt;/strong&gt; No &lt;code class=&quot;highlighter-rouge&quot;&gt;prepend_view_path&lt;/code&gt;, no ivars set by a &lt;code class=&quot;highlighter-rouge&quot;&gt;before_action&lt;/code&gt;, no helpers from another controller.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;_url&lt;/code&gt; helpers raise &lt;code class=&quot;highlighter-rouge&quot;&gt;Missing host to link to!&lt;/code&gt;&lt;/strong&gt; on Rails 7.1 and later, because the renderer’s defaults no longer include an &lt;code class=&quot;highlighter-rouge&quot;&gt;http_host&lt;/code&gt;. The &lt;code class=&quot;highlighter-rouge&quot;&gt;_path&lt;/code&gt; helpers work.&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, incidentally, the best argument in favor of &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. Two &lt;code class=&quot;highlighter-rouge&quot;&gt;base64 -d&lt;/code&gt; calls separate your page source from the string &lt;code class=&quot;highlighter-rouge&quot;&gt;gid://app/Account/5&lt;/code&gt;, and nobody needs your key to run them.&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. Anyone holding a valid signed name can subscribe, including a former employee whose access you revoked, indefinitely: these names do not expire.&lt;/p&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;And if you need real authorization, for example because membership can be revoked, you have to write your own channel and do the check before &lt;code class=&quot;highlighter-rouge&quot;&gt;stream_from&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;two-plumbing-traps&quot;&gt;Two 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; Your retry policy, your queue and your callbacks do not apply. All three jobs do &lt;code class=&quot;highlighter-rouge&quot;&gt;discard_on ActiveJob::DeserializationError&lt;/code&gt;: a record deleted between enqueue and execution makes the broadcast vanish, with no retry and no error.&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 classic visit, the order is: &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt;, then the &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt; swap, so &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; on the old controllers and &lt;code class=&quot;highlighter-rouge&quot;&gt;connect()&lt;/code&gt; on the new ones, then &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:render&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:load&lt;/code&gt;. Note that &lt;strong&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt; comes after &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt;&lt;/strong&gt;: the snapshot is taken first. That is why &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; was historically the right place to undo whatever a library had injected.&lt;/p&gt;

&lt;p&gt;Under morph, there is &lt;strong&gt;neither &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; nor &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect&lt;/code&gt;/&lt;code class=&quot;highlighter-rouge&quot;&gt;connect&lt;/code&gt;&lt;/strong&gt; for elements modified in place. The callbacks only fire if the node is actually added or removed, if it is reparented through the pantry on the &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt; path, or if the value of &lt;code class=&quot;highlighter-rouge&quot;&gt;data-controller&lt;/code&gt; changes.&lt;/p&gt;

&lt;p&gt;The recommended reconnection pattern comes, once again, from Turbo’s test fixtures:&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;It is expensive on a large page, since it reconnects every controller of every morphed element, but it is the most official answer in existence.&lt;/p&gt;

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

&lt;p&gt;The general rule: &lt;strong&gt;any library that injects DOM the server does not render, or that 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, is incompatible with morphing without explicit protection.&lt;/strong&gt;&lt;/p&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;Chart.js, 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;. The &lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;script&amp;gt;&lt;/code&gt; that would recreate it 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;&lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo-permanent&lt;/code&gt; &lt;strong&gt;with an &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt;&lt;/strong&gt;, or destroy and reinitialize on &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:morph-element&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Leaflet, Mapbox GL&lt;/td&gt;
      &lt;td&gt;Dead map, gray 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 injected panes are removed, the container survives&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;map.remove()&lt;/code&gt; in &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;invalidateSize()&lt;/code&gt; after reconnection&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&lt;/td&gt;
      &lt;td&gt;If it is reparented on the &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt; path, it unsubscribes then resubscribes&lt;/td&gt;
      &lt;td&gt;Give it a stable &lt;code class=&quot;highlighter-rouge&quot;&gt;id&lt;/code&gt; and move it out of reordered areas&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 is worth knowing, because it is the best general 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;Turbo turns your application into a long-running process. Everything you create in &lt;code class=&quot;highlighter-rouge&quot;&gt;connect()&lt;/code&gt; must be torn down in &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt;, without exception: the &lt;code class=&quot;highlighter-rouge&quot;&gt;setInterval&lt;/code&gt; timers, the &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;, the &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), the Action Cable subscriptions, the Chart.js instances.&lt;/p&gt;

&lt;p&gt;The case of WebGL contexts is worth citing: browsers cap them at around sixteen. One Mapbox map left undestroyed, sixteen navigations, and all your canvases go blank.&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 reloads the page completely.&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; and does &lt;code class=&quot;highlighter-rouge&quot;&gt;window.location.href = …&lt;/code&gt;. Offline, that means: the browser’s network error page, and your application destroyed. It is the worst possible moment to lose client state.&lt;/p&gt;

&lt;p&gt;That detail changes the nature of the listener below. The &lt;code class=&quot;highlighter-rouge&quot;&gt;preventDefault()&lt;/code&gt; is not cosmetic: it makes Turbo’s internal guard return &lt;code class=&quot;highlighter-rouge&quot;&gt;false&lt;/code&gt;, which short-circuits the error handling and therefore &lt;strong&gt;the reload&lt;/strong&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;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;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&#39;s fetch()&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;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;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;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;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;Two ways to break Turbo from a service worker:&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; Turbo records &lt;code class=&quot;highlighter-rouge&quot;&gt;contentTypeMismatch&lt;/code&gt; and abandons the visit silently.&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, which the service worker answers again from the cache. You have a reload loop.&lt;/p&gt;

&lt;p&gt;Three constraints specific to Rails 8:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Put the service worker at a stable root URL.&lt;/strong&gt; An asset digested by Propshaft cannot control a scope beyond its own directory, and its URL changes as soon as its content changes. Two options: &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. &lt;code class=&quot;highlighter-rouge&quot;&gt;importScripts()&lt;/code&gt; or a classic worker with no imports.&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;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;880&quot; height=&quot;476&quot; src=&quot;/images/posts/turbo/en/07-websocket.svg&quot; alt=&quot;Timeline showing broadcasts lost during a WebSocket outage, with no error and no log, and the callout on the connected attribute as the only signal.&quot; /&gt;
  &lt;figcaption&gt;The only signal available is an attribute on an element. There is no event, no sequence number, no catch-up.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;This is, in my view, the least documented production risk in all of Hotwire.&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 is then silently, indefinitely stale. No error, no indication, nothing.&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 entire 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;. No event is dispatched. Hence the workaround, which is 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 and the focus. &lt;strong&gt;This is the best practical argument in favor of pairing morphing with broadcasts&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 silent staleness.&lt;/p&gt;

&lt;h2 id=&quot;the-decision-tree&quot;&gt;The decision tree&lt;/h2&gt;

&lt;figure class=&quot;schema&quot;&gt;
  &lt;img width=&quot;940&quot; height=&quot;710&quot; src=&quot;/images/posts/turbo/en/08-decision.svg&quot; alt=&quot;Four cascading questions: does the URL have to change, a single zone with its own URL, several zones or a server-driven update, purely client state. Leading respectively to Drive, Frame, Streams, Stimulus.&quot; /&gt;
  &lt;figcaption&gt;The first yes gives you the tool. Going one step further down always costs more than stopping.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The test that settles it for frames is the URL test: &lt;strong&gt;if there is no URL that renders this fragment on its own, a frame is not what you want.&lt;/strong&gt; A frame is a mini browser, with an address, a loading state and a history. Without an address of its own, it brings you nothing and costs you the identifier constraint.&lt;/p&gt;

&lt;p&gt;The test that settles it for streams is the origin test: &lt;strong&gt;if nobody asked for anything, it is a broadcast.&lt;/strong&gt; If someone has just clicked and several zones have to change, it is a &lt;code class=&quot;highlighter-rouge&quot;&gt;.turbo_stream&lt;/code&gt; response. These are two very different uses that share one format.&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 goes out, nothing moves, &lt;strong&gt;no error at all&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;A &lt;code class=&quot;highlighter-rouge&quot;&gt;data-turbo=&quot;false&quot;&lt;/code&gt; ancestor, or the form is inside a frame you had not noticed&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 and nothing at all happens&lt;/td&gt;
      &lt;td&gt;Turbo tests &lt;code class=&quot;highlighter-rouge&quot;&gt;startsWith(&quot;text/vnd.turbo-stream.html&quot;)&lt;/code&gt;. Otherwise it does not intercept, and the response goes back into normal handling, with no error&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, and &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; no longer fires&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;&lt;code class=&quot;highlighter-rouge&quot;&gt;&amp;lt;%= yield :head %&amp;gt;&lt;/code&gt; missing from the layout: &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo_refreshes_with&lt;/code&gt; writes nothing without it&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;The request has to leave from &lt;strong&gt;and land on the same URL&lt;/strong&gt;&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. In a loop, look for a dynamically injected asset or a service worker&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 elsewhere in the document, or the tag changed&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Works in Chrome, breaks in Safari, under morph&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;moveBefore&lt;/code&gt; versus &lt;code class=&quot;highlighter-rouge&quot;&gt;insertBefore&lt;/code&gt; in idiomorph’s pantry. It is not your 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;Intended behavior: the cached snapshot is rendered first. &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. Destroy it in &lt;code class=&quot;highlighter-rouge&quot;&gt;disconnect()&lt;/code&gt;, not in &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:before-cache&lt;/code&gt; if you morph&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. Signing is not authorizing&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;A broadcast arrives twice for its author&lt;/td&gt;
      &lt;td&gt;Synchronous variant with no &lt;code class=&quot;highlighter-rouge&quot;&gt;request_id&lt;/code&gt;, or broadcast from a job&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&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 triggers a full reload. &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; prevents it&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Changes are never broadcast from a &lt;code class=&quot;highlighter-rouge&quot;&gt;rails runner&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;The process ends before the debounce task scheduled 0.5 s out&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, worth knowing: 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, and the fact that &lt;code class=&quot;highlighter-rouge&quot;&gt;turbo:frame-render&lt;/code&gt; is declared cancelable even though canceling it produces no effect (the return value is discarded).&lt;/p&gt;

&lt;p&gt;Finally, the reference versions for this article are pinned to Turbo 8.0.23 and turbo-rails 2.0.23. Almost everything above was verified by reading the code of those two versions rather than the documentation, precisely because that is where the gaps are.&lt;/p&gt;</content>
    <author><name>Nathan Le Ray</name></author>
    <category term="ruby" />
    <category term="Hotwire" />
    <category term="Turbo" />
    <category term="Rails" />
    <category term="Morphing" />
    <category term="Stimulus" />
    <summary type="html">Drive, Frames, Streams and morphing explained through what connects them: who designates the target of the replacement. With the traps the official documentation does not mention, and a symptom index.</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">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 were not decorative details. They were 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: 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: they are residual issues still to be fixed 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: it 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: in other words, 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></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 not the outage. It 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: 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, the lesson is not “AI is unreliable”. It is more precise than that: 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 is not “does the demo work”. It is this one, 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 is not the AI. It is that a dependency 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></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">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></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: 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: 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, if I had to sum up: 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></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 isn’t never adding one, it’s 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;This isn’t a case against Algolia. 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></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 isn’t typing speed. It’s 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;Three things shifted in the way I work.&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’s there 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: less production, 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></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. It’s mostly 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. An electronic invoice, in the sense of the reform, is not a PDF attachment. It’s 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. That’s where 90% of the effort goes, and it’s the part no platform will do 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”. It’s &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></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">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. In other words, 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: 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></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;Three invariants drive the whole architecture:&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: 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. Culprit: 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></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>
  <entry xml:lang="en">
    <title type="html">Setup Scaleway Object Storage for Rails Active Storage</title>
    <link href="https://sxnlabs.com/en/cloud/2025/05/07/setup-scaleway-object-storage-for-active-storage/" rel="alternate" type="text/html" title="Setup Scaleway Object Storage for Rails Active Storage" />
    <published>2025-05-07T15:00:00+02:00</published>
    <updated>2025-05-07T15:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/cloud/2025/05/07/setup-scaleway-object-storage-for-active-storage/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/cloud/2025/05/07/setup-scaleway-object-storage-for-active-storage/">&lt;p&gt;Scaleway provides an &lt;strong&gt;object storage&lt;/strong&gt; service compatible with the S3 API.&lt;/p&gt;

&lt;p&gt;Here’s how to configure your bucket via &lt;strong&gt;Terraform&lt;/strong&gt;, though you can also do it directly through the console.&lt;/p&gt;

&lt;p&gt;Table of contents:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;a href=&quot;#iam-permissions-management&quot;&gt;IAM Permissions Management&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#bucket-creation&quot;&gt;Bucket Creation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#ruby-on-rails-configuration&quot;&gt;Ruby on Rails Configuration&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note: the &lt;code class=&quot;highlighter-rouge&quot;&gt;var.scw_project&lt;/code&gt; variable contains your project UUID, which you can find in the console.&lt;/p&gt;

&lt;h2 id=&quot;iam-permissions-management&quot;&gt;IAM Permissions Management&lt;/h2&gt;

&lt;p&gt;First, we’ll create an &lt;code class=&quot;highlighter-rouge&quot;&gt;application&lt;/code&gt; to which we’ll assign permissions and from which we’ll retrieve the key pair for authentication.&lt;/p&gt;

&lt;p&gt;Here, I’m granting full permissions (&lt;code class=&quot;highlighter-rouge&quot;&gt;ObjectStorageFullAccess&lt;/code&gt;) for the Object Storage API, but you can be more restrictive if you prefer.&lt;/p&gt;

&lt;div class=&quot;language-hcl 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;# iam.tf&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;scaleway_iam_application&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_app&quot;&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;s2&quot;&gt;&quot;MyApp&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;description&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;MyApp application&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;scaleway_iam_api_key&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_app&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;application_id&lt;/span&gt;     &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scaleway_iam_application&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;description&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;MyApp API Key&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;default_project_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;scw_project&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;scaleway_iam_policy&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_app_object_storage&quot;&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;s2&quot;&gt;&quot;MyApp Object Storage&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;description&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Gives MyApp full access to object storage&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;application_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scaleway_iam_application&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;rule&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;project_ids&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;var&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;scw_project&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;permission_set_names&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;s2&quot;&gt;&quot;ObjectStorageFullAccess&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;Add the outputs below to retrieve the authentication key pair:&lt;/p&gt;

&lt;div class=&quot;language-hcl 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;# outputs.tf&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;output&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_app_access_key&quot;&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;nx&quot;&gt;scaleway_iam_api_key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;access_key&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;sensitive&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;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;output&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_app_secret_key&quot;&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;nx&quot;&gt;scaleway_iam_api_key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;secret_key&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;sensitive&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;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;bucket-creation&quot;&gt;Bucket creation&lt;/h2&gt;

&lt;p&gt;Let’s move on to creating the bucket. I’ve added rules to migrate objects between storage classes, but you’re not required to use them.
&lt;strong&gt;CORS&lt;/strong&gt; rules are important if you want to use &lt;a href=&quot;https://edgeguides.rubyonrails.org/active_storage_overview.html#direct-uploads&quot;&gt;direct uploads&lt;/a&gt;.&lt;/p&gt;

&lt;div class=&quot;language-hcl 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;# object_storage.tf&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;scaleway_object_bucket&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_app&quot;&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;s2&quot;&gt;&quot;my_app&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;region&lt;/span&gt;     &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;fr-par&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;project_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;scw_project&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;tags&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;s2&quot;&gt;&quot;Application&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;MyApp&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;lifecycle_rule&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;                                     &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;abort_incomplete_multipart_upload&quot;&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;abort_incomplete_multipart_upload_days&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;7&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;lifecycle_rule&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;onezone_ia&quot;&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;transition&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;days&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;storage_class&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ONEZONE_IA&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;lifecycle_rule&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;glacier&quot;&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;transition&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;days&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;120&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;storage_class&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GLACIER&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;cors_rule&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;allowed_methods&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;s2&quot;&gt;&quot;GET&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;HEAD&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;POST&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;PUT&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;DELETE&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;allowed_headers&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;s2&quot;&gt;&quot;*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;allowed_origins&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;s2&quot;&gt;&quot;*&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;expose_headers&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;s2&quot;&gt;&quot;ETag&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;max_age_seconds&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3600&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 last thing we need to configure here is the bucket policy to ensure that the &lt;code class=&quot;highlighter-rouge&quot;&gt;application&lt;/code&gt; can access it.
Note that I’ve also added a rule for my own user so I can still access the bucket from the console.&lt;/p&gt;

&lt;div class=&quot;language-hcl 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;# object_storage.tf&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;scaleway_object_bucket_policy&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_app&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;bucket&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scaleway_object_bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_app&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;nx&quot;&gt;policy&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;jsonencode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;Id&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_app&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;Version&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;2023-04-17&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;Statement&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;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;Effect&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Allow&quot;&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;Action&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;s2&quot;&gt;&quot;*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;Principal&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;SCW&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;application_id:${scaleway_iam_application.my_app.id}&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;Resource&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;scaleway_object_bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_app&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;s2&quot;&gt;&quot;${scaleway_object_bucket.my_app.name}/*&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;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;Effect&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Allow&quot;&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;Action&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;s2&quot;&gt;&quot;*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;Principal&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;SCW&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;user_id:${var.scw_my_user}&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;Resource&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;scaleway_object_bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_app&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;s2&quot;&gt;&quot;${scaleway_object_bucket.my_app.name}/*&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;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;h2 id=&quot;ruby-on-rails-configuration&quot;&gt;Ruby on Rails Configuration&lt;/h2&gt;

&lt;p&gt;We can now configure &lt;strong&gt;Active Storage&lt;/strong&gt; with our setup.&lt;/p&gt;

&lt;div class=&quot;language-yaml 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/storage.yml&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;scaleway&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;S3&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;access_key_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&amp;lt;%= Rails.application.credentials.dig(:scaleway, :access_key) %&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;secret_access_key&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&amp;lt;%= Rails.application.credentials.dig(:scaleway, :secret_key) %&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;region&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;fr-par&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;bucket&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;my_app&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;endpoint&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;https://s3.fr-par.scw.cloud&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Retrieve the key pair using the commands below:&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;tf output my_app_access_key
tf output my_app_secret_key
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And that’s it!&lt;/p&gt;</content>
    <author><name>Nathan Le Ray</name></author>
    <category term="cloud" />
    <category term="Active Storage" />
    <category term="Scaleway" />
    <category term="Object Storage" />
    <category term="Ruby on Rails" />
    <category term="Infra" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2025-05-07-setup-scaleway-object-storage-for-active-storage.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">Upgrade your Rails app from esbuild to import-maps</title>
    <link href="https://sxnlabs.com/en/ruby/2023/11/10/upgrade-rails-from-to-esbuild-to-import-maps/" rel="alternate" type="text/html" title="Upgrade your Rails app from esbuild to import-maps" />
    <published>2023-11-10T11:00:00+02:00</published>
    <updated>2023-11-10T11:00:00+02:00</updated>
    <id>https://sxnlabs.com/en/ruby/2023/11/10/upgrade-rails-from-to-esbuild-to-import-maps/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/ruby/2023/11/10/upgrade-rails-from-to-esbuild-to-import-maps/">&lt;p&gt;The &lt;a href=&quot;https://github.com/rails/importmap-rails&quot;&gt;import-maps&lt;/a&gt; gem finally allows us to get rid of JavaScript tooling in our Ruby on Rails projects, whether it’s esbuild or webpacker.&lt;/p&gt;

&lt;p&gt;Let’s see together how to convert a project using esbuild, in my case, to import-maps.&lt;/p&gt;

&lt;h2 id=&quot;initial-state&quot;&gt;Initial State&lt;/h2&gt;

&lt;p&gt;Our layout links the &lt;code class=&quot;highlighter-rouge&quot;&gt;application.js&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;application.css&lt;/code&gt; files.&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;# views/layouts/application.html.erb
&lt;span class=&quot;nt&quot;&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;c&quot;&gt;&amp;lt;!-- ... --&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stylesheet_link_tag&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;application&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;data-turbo-track&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;reload&quot;&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;javascript_include_tag&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;application&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;data-turbo-track&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;reload&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;defer: &lt;/span&gt;&lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The CSS file imports styles from various NPM packages located in the &lt;code class=&quot;highlighter-rouge&quot;&gt;node_modules&lt;/code&gt; directory as well as &lt;a href=&quot;https://tailwindcss.com&quot;&gt;TailwindCSS&lt;/a&gt; directives.&lt;/p&gt;

&lt;div class=&quot;language-css 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;s1&quot;&gt;&quot;notyf/notyf.min&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;@import&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&quot;trix/dist/trix&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;@import&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&quot;actiontext.css&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;@tailwind&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;@tailwind&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;components&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;@tailwind&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;utilities&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 JS file imports different packages as well as our &lt;a href=&quot;https://stimulus.hotwired.dev&quot;&gt;Stimulus&lt;/a&gt; controllers.&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;import&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@hotwired/turbo-rails&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;import&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;trix&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;import&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@rails/actiontext&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;import&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;./controllers&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A setup that couldn’t be more classic.&lt;/p&gt;

&lt;h2 id=&quot;installing-import-map&quot;&gt;Installing &lt;code class=&quot;highlighter-rouge&quot;&gt;import-map&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;If your app wasn’t created with Rails 7+, run the commands below to install the gem.&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;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;bundle add importmap-rails
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then:&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;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;bundle &lt;span class=&quot;nb&quot;&gt;exec &lt;/span&gt;rails importmap:install
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You will find the new &lt;code class=&quot;highlighter-rouge&quot;&gt;importmap.rb&lt;/code&gt; file in the &lt;code class=&quot;highlighter-rouge&quot;&gt;config&lt;/code&gt; directory, which somewhat replaces the &lt;code class=&quot;highlighter-rouge&quot;&gt;package.json&lt;/code&gt; file that read our NPM dependencies.&lt;/p&gt;

&lt;p&gt;There, you’ll find a list of &lt;code class=&quot;highlighter-rouge&quot;&gt;pins&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Packages can be loaded from several sources:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A URL: &lt;code class=&quot;highlighter-rouge&quot;&gt;pin &quot;notyf&quot;, to: &quot;https://ga.jspm.io/npm:notyf@3.10.0/notyf.es.js&quot;&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;A gem: &lt;code class=&quot;highlighter-rouge&quot;&gt;pin &quot;@hotwired/turbo-rails&quot;, to: &quot;turbo.min.js&quot;&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;A local file: &lt;code class=&quot;highlighter-rouge&quot;&gt;pin &quot;application&quot;&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;A local directory: &lt;code class=&quot;highlighter-rouge&quot;&gt;pin_all_from &quot;app/javascript/controllers&quot;, under: &quot;controllers&quot;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;update&quot;&gt;Update&lt;/h2&gt;

&lt;p&gt;The &lt;code class=&quot;highlighter-rouge&quot;&gt;import-map&lt;/code&gt; installation has already prefilled the &lt;code class=&quot;highlighter-rouge&quot;&gt;importmap.rb&lt;/code&gt; file for you.&lt;/p&gt;

&lt;p&gt;You should find there the &lt;code class=&quot;highlighter-rouge&quot;&gt;@hotwired/stimulus-loading&lt;/code&gt; pin, which is a new method of loading for Stimulus controllers that doesn’t require manually registering them.&lt;/p&gt;

&lt;p&gt;You can now &lt;em&gt;pin 🍆&lt;/em&gt; your NPM packages to include them in the import list.&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;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;./bin/importmap pin notyf
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This command will automatically add all the dependencies of the &lt;em&gt;notyf&lt;/em&gt; package.&lt;/p&gt;

&lt;p&gt;Here’s the list of &lt;em&gt;pins&lt;/em&gt; for me:&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/importmap.rb&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;application&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;preload: &lt;/span&gt;&lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;i18n&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;preload: &lt;/span&gt;&lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;@hotwired/turbo-rails&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;to: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;turbo.min.js&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;preload: &lt;/span&gt;&lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;@hotwired/stimulus&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;to: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;stimulus.min.js&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;preload: &lt;/span&gt;&lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;@hotwired/stimulus-loading&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;to: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;stimulus-loading.js&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;preload: &lt;/span&gt;&lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin_all_from&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;app/javascript/utils&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;under: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;utils&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin_all_from&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;app/javascript/controllers&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;under: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;controllers&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;trix&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;@rails/actioncable&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;to: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;https://ga.jspm.io/npm:@rails/actioncable@7.1.1/app/assets/javascripts/actioncable.esm.js&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;@rails/actiontext&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;to: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;actiontext.js&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pin&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;notyf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;to: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;https://ga.jspm.io/npm:notyf@3.10.0/notyf.es.js&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You can now modify your layout to load the &lt;em&gt;import-maps&lt;/em&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;# views/layouts/application.html.erb
&lt;span class=&quot;nt&quot;&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;c&quot;&gt;&amp;lt;!-- ... --&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stylesheet_link_tag&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;trix&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;sx&quot;&gt;%%= stylesheet_link_tag &quot;application&quot;, &quot;data-turbo-track&quot;: &quot;reload&quot; &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;sx&quot;&gt; javascript_importmap_tags &lt;/span&gt;&lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;“We are now loading CSS files manually and one by one, to avoid having a single large bundle and to leverage the capabilities of HTTP 2 for loading multiple files quickly.&lt;/p&gt;

&lt;h2 id=&quot;cleanup&quot;&gt;Cleanup&lt;/h2&gt;

&lt;p&gt;You can now delete all the files related to JavaScript tooling:&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;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;rm&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-rf&lt;/span&gt; package.json yarn.lock node_modules
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Welcome to a rational world without all the complexity of frontend tools! 🥳&lt;/p&gt;

&lt;p&gt;If you want to &lt;strong&gt;react to this article&lt;/strong&gt; or need &lt;strong&gt;assistance with updating your Ruby on Rails applications&lt;/strong&gt;, contact me on &lt;a href=&quot;https://www.linkedin.com/in/nathanleray/&quot;&gt;LinkedIn&lt;/a&gt;, &lt;a href=&quot;https://sxnlabs.social/@nathan&quot;&gt;Mastodon&lt;/a&gt;.”&lt;/p&gt;</content>
    <author><name>Nathan Le Ray</name></author>
    <category term="ruby" />
    <category term="Javascript" />
    <category term="Ruby on Rails" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2023-11-10-upgrade-rails-from-to-esbuild-to-import-maps.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">Control and document the variables of Ruby on Rails partials</title>
    <link href="https://sxnlabs.com/en/ruby/2023/11/09/rails-partials-strict-locals/" rel="alternate" type="text/html" title="Control and document the variables of Ruby on Rails partials" />
    <published>2023-11-09T11:40:00+02:00</published>
    <updated>2023-11-09T11:40:00+02:00</updated>
    <id>https://sxnlabs.com/en/ruby/2023/11/09/rails-partials-strict-locals/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/ruby/2023/11/09/rails-partials-strict-locals/">&lt;p&gt;The &lt;strong&gt;partials in Ruby on Rails&lt;/strong&gt; can quickly become &lt;strong&gt;a nightmare&lt;/strong&gt; to manage because the only way to have the list of variables (locals in Rails jargon) used by a partial is to directly read its source code, which often contains several hundred lines of HTML code…&lt;/p&gt;

&lt;p&gt;Since the release of &lt;strong&gt;Ruby on Rails 7.1&lt;/strong&gt;, it is possible to use &lt;em&gt;magic comments&lt;/em&gt; at the top of your partials to constrain and document the use of locals in that partial.&lt;/p&gt;

&lt;p&gt;Let’s see right away how this works. 👏&lt;/p&gt;

&lt;p&gt;Let’s start by defining the partial’s variables as follows:&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;# app/views/users/_user_card.html.erb


My name is &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
Reach me at &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;email&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;Then, we pass these variables from the template in the &lt;code class=&quot;highlighter-rouge&quot;&gt;locals&lt;/code&gt; &lt;em&gt;hash&lt;/em&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;# app/views/users/show.html.erb

&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;ss&quot;&gt;partial: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;user_card&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;name: &lt;/span&gt;&lt;span class=&quot;vi&quot;&gt;@user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;full&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;ss&quot;&gt;email: &lt;/span&gt;&lt;span class=&quot;vi&quot;&gt;@user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;email&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;As defined, the variables &lt;code class=&quot;highlighter-rouge&quot;&gt;name&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;email&lt;/code&gt; are mandatory, and their absence will cause an error.&lt;/p&gt;

&lt;p&gt;Let’s remove the email variable to see what happens.&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;# app/views/users/show.html.erb

&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;ss&quot;&gt;partial: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;user_card&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;name: &lt;/span&gt;&lt;span class=&quot;vi&quot;&gt;@user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;full&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;An error is immediately raised.&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;ActionView::Template::Error (missing local: :email):

app/views/users/show.html.erb:1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Now, let’s try to pass an undefined variable.&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;# app/views/users/show.html.erb

&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;ss&quot;&gt;partial: &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;user_card&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;name: &lt;/span&gt;&lt;span class=&quot;vi&quot;&gt;@user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;full&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;ss&quot;&gt;email: &lt;/span&gt;&lt;span class=&quot;vi&quot;&gt;@user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;email&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;ss&quot;&gt;phones: &lt;/span&gt;&lt;span class=&quot;vi&quot;&gt;@user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;phones&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A new error is raised, this time to tell us that this variable is not accepted because it was not declared in the partial.&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;ActionView::Template::Error (unknown local: :phones):

app/views/users/show.html.erb:1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Thanks to these two errors, we are now certain of the variables accepted by our partial and we have at the same time gained a bit of documentation! 🎉&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default Values&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is possible to give default values to variables, which will allow us not to have to pass them each time we render.&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;# app/views/users/_user_card.html.erb


My name is &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
Reach me at &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;email&lt;/span&gt; &lt;span class=&quot;cp&quot;&gt;%&amp;gt;&lt;/span&gt;
You can also give me a call at &lt;span class=&quot;cp&quot;&gt;&amp;lt;%=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;phones&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;, &quot;&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This way, it is possible not to provide the &lt;code class=&quot;highlighter-rouge&quot;&gt;phones&lt;/code&gt; variable to the partial without the call to the &lt;code class=&quot;highlighter-rouge&quot;&gt;join&lt;/code&gt; method failing, because it has been called on &lt;code class=&quot;highlighter-rouge&quot;&gt;nil&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disabling Variables&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you do not want any variables to be passed to your partial, this is also possible!&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;# app/views/users/_user_card.html.erb


My name is John
Reach me at john@doe.com
You can also give me a call at 0123456789
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If we attempt to pass variables during rendering, the error below will be raised.&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;ActionView::Template::Error (no locals accepted):

app/views/users/show.html.erb:1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That concludes our little tour of this new feature in Rails 7.1, allowing us to exercise better control over the variables passed to our partials while documenting them.&lt;/p&gt;</content>
    <author><name>Nathan Le Ray</name></author>
    <category term="ruby" />
    <category term="Ruby" />
    <category term="Ruby on Rails" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2023-11-09-rails-partials-strict-locals.en.png" />
  </entry>
  <entry xml:lang="en">
    <title type="html">Manage Your Ruby Processes with Overmind</title>
    <link href="https://sxnlabs.com/en/ruby/2023/11/03/manage-ruby-processes-overmind/" rel="alternate" type="text/html" title="Manage Your Ruby Processes with Overmind" />
    <published>2023-11-03T16:30:00+02:00</published>
    <updated>2023-11-03T16:30:00+02:00</updated>
    <id>https://sxnlabs.com/en/ruby/2023/11/03/manage-ruby-processes-overmind/</id>
    <content type="html" xml:base="https://sxnlabs.com/en/ruby/2023/11/03/manage-ruby-processes-overmind/">&lt;p&gt;Ruby on Rails 7 has introduced a new executable &lt;code class=&quot;highlighter-rouge&quot;&gt;bin/dev&lt;/code&gt;.
It won’t be present by default unless you create a new application with the &lt;code class=&quot;highlighter-rouge&quot;&gt;--css&lt;/code&gt; option or run the &lt;code class=&quot;highlighter-rouge&quot;&gt;css:install&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;This file calls the &lt;a href=&quot;https://github.com/ddollar/foreman&quot;&gt;Foreman&lt;/a&gt; Gem which, in turn, executes the processes defined in the &lt;code class=&quot;highlighter-rouge&quot;&gt;Procfile.dev&lt;/code&gt; at the root of your project.&lt;/p&gt;

&lt;p&gt;This way of operating allows you to launch as many processes as you want in parallel, with a single command.&lt;/p&gt;

&lt;p&gt;Here is an example of a &lt;code class=&quot;highlighter-rouge&quot;&gt;Procfile&lt;/code&gt; from one of my projects.&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;web: bin/rails server -p 3000
css: bin/rails tailwindcss:watch
worker: bundle exec good_job start
guard: bundle exec guard
stripe: stripe listen --forward-to localhost:3000/pay/webhooks/stripe
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;All this works perfectly until the moment you need to debug your application with breakpoints.
Foreman does not allow you to access the debug console of a particular process.&lt;/p&gt;

&lt;p&gt;This is where &lt;a href=&quot;https://github.com/DarthSim/overmind&quot;&gt;Overmind&lt;/a&gt; comes into play.
This program does exactly the same thing as Foreman while allowing you to connect to your processes.&lt;/p&gt;

&lt;p&gt;Install Overmind on your machine as follows:&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;&lt;span class=&quot;c&quot;&gt;# on macOS (with homebrew)&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;brew &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;tmux

&lt;span class=&quot;c&quot;&gt;# on Ubuntu&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;apt-get &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;tmux
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then start your processes:&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;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;overmind s &lt;span class=&quot;nt&quot;&gt;-f&lt;/span&gt; Procfile.dev
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/figures/2023-11-03/overmind-server.webp&quot; alt=&quot;Overmind server command&quot; /&gt;&lt;/p&gt;

&lt;p&gt;I will now put a breakpoint in a Rails controller of the &lt;em&gt;web&lt;/em&gt; process.&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;HomeController&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;ApplicationController&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;index&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;debugger&lt;/span&gt;
    &lt;span class=&quot;c1&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;From another terminal, but in the same directory (where your Procfile.dev is located), run the following command:&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;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;overmind c web
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/figures/2023-11-03/overmind-connect.webp&quot; alt=&quot;Overmind connect command&quot; /&gt;&lt;/p&gt;

&lt;p&gt;You can now access the Ruby console! 🎉&lt;/p&gt;

&lt;p&gt;Of course, this feature is not limited to Ruby and works with any program/language.&lt;/p&gt;</content>
    <author><name>Nathan Le Ray</name></author>
    <category term="ruby" />
    <category term="Debugging" />
    <category term="Ruby" />
    <category term="Tools" />
    <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sxnlabs.com/images/og/2023-11-03-manage-ruby-processes-overmind.en.png" />
  </entry>
</feed>

