<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Danny Holloran</title>
    <description>The latest articles on DEV Community by Danny Holloran (@grimicorn).</description>
    <link>https://dev.to/grimicorn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3951431%2Fab822d20-286b-4190-86ce-c6a0bcbb8319.jpeg</url>
      <title>DEV Community: Danny Holloran</title>
      <link>https://dev.to/grimicorn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/grimicorn"/>
    <language>en</language>
    <item>
      <title>Async Svelte: Using await Directly in Your Components</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Mon, 31 Aug 2026 08:05:37 +0000</pubDate>
      <link>https://dev.to/grimicorn/async-svelte-using-await-directly-in-your-components-1bgf</link>
      <guid>https://dev.to/grimicorn/async-svelte-using-await-directly-in-your-components-1bgf</guid>
      <description>&lt;p&gt;Every Svelte codebase eventually grows a little pile of scaffolding around asynchronous data. A &lt;code&gt;let data = $state(null)&lt;/code&gt;, an &lt;code&gt;$effect&lt;/code&gt; that fetches and assigns, a &lt;code&gt;loading&lt;/code&gt; flag, an &lt;code&gt;error&lt;/code&gt; flag, and a &lt;code&gt;{#if loading}&lt;/code&gt; in the template. Or the &lt;code&gt;{#await}&lt;/code&gt; block, which is fine for one promise but nests badly the moment you need two. Either way, you end up writing plumbing rather than describing your UI.&lt;/p&gt;

&lt;p&gt;Since Svelte 5.36, you can skip most of that. The &lt;code&gt;await&lt;/code&gt; keyword works in three places it previously did not: at the top level of a component's &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;, inside &lt;code&gt;$derived(...)&lt;/code&gt;, and directly in your markup. It is still behind an experimental flag, but the design is worth understanding now because it changes how you think about loading states.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it on, and what changes
&lt;/h2&gt;

&lt;p&gt;Async Svelte is opt-in. Add &lt;code&gt;experimental.async&lt;/code&gt; wherever you configure the compiler, which usually means &lt;code&gt;svelte.config.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;compilerOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;experimental&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs say this flag disappears in Svelte 6, so today's opt-in is tomorrow's default. Once it is on, this is legal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"number"&lt;/span&gt; &lt;span class="na"&gt;bind:value=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"number"&lt;/span&gt; &lt;span class="na"&gt;bind:value=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; + &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; = &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is what does &lt;em&gt;not&lt;/em&gt; happen. Bump &lt;code&gt;a&lt;/code&gt; to 2 and the paragraph does not flash &lt;code&gt;2 + 2 = 3&lt;/code&gt; while the promise is in flight. Svelte holds the whole update until &lt;code&gt;add(a, b)&lt;/code&gt; resolves, then swaps everything at once. That is the headline feature: &lt;strong&gt;synchronized updates&lt;/strong&gt;. You never render a torn UI where half the values are new and half are stale, which is exactly the bug that &lt;code&gt;loading&lt;/code&gt; flags exist to paper over.&lt;/p&gt;

&lt;p&gt;Updates can also overlap. A fast update lands while a slower earlier one is still running, so a quick keystroke is not stuck behind a slow one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency is automatic, waterfalls are not
&lt;/h2&gt;

&lt;p&gt;Two independent &lt;code&gt;await&lt;/code&gt; expressions in markup run in parallel, even though they read as sequential:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;two&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both kick off immediately. The same is &lt;em&gt;not&lt;/em&gt; true inside your &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;, where &lt;code&gt;await&lt;/code&gt; behaves like ordinary JavaScript and runs top to bottom. Svelte will warn you about this with an &lt;code&gt;await_waterfall&lt;/code&gt; warning when you write something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$derived&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$derived&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;two&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;b&lt;/code&gt; is not created until &lt;code&gt;a&lt;/code&gt; resolves. Once both exist they update independently, but that first pass is a waterfall. If you have seen the same class of bug in a React &lt;code&gt;useEffect&lt;/code&gt; chain, this is the familiar shape with a compiler warning attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading states move into boundaries
&lt;/h2&gt;

&lt;p&gt;With no &lt;code&gt;loading&lt;/code&gt; variable to hang a spinner on, placeholder UI moves to &lt;code&gt;&amp;lt;svelte:boundary&amp;gt;&lt;/code&gt; and its &lt;code&gt;pending&lt;/code&gt; snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;svelte:boundary&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;delayed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt; &lt;span class="nf"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;loading...&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/svelte:boundary&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;pending&lt;/code&gt; snippet shows when the boundary is first created and stays until every &lt;code&gt;await&lt;/code&gt; inside it resolves. It deliberately does not reappear for later updates, since those are globally coordinated and rendering a full-page skeleton on every keystroke would be worse than useless.&lt;/p&gt;

&lt;p&gt;For subsequent async work, &lt;code&gt;$effect.pending()&lt;/code&gt; tells you how many promises are outstanding in the current boundary, not counting child boundaries. That is what you reach for when you want a small "validating..." spinner next to a form field rather than blanking the section. There is also &lt;code&gt;settled()&lt;/code&gt;, a promise that resolves once state changes and their async consequences have been flushed to the DOM.&lt;/p&gt;

&lt;p&gt;Errors get the same treatment. Anything thrown inside an &lt;code&gt;await&lt;/code&gt; expression bubbles to the nearest boundary, where a &lt;code&gt;failed&lt;/code&gt; snippet receives the &lt;code&gt;error&lt;/code&gt; and a &lt;code&gt;reset&lt;/code&gt; function. Worth remembering: boundaries catch errors during rendering and effects, not errors from event handlers or a stray &lt;code&gt;setTimeout&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts still in motion
&lt;/h2&gt;

&lt;p&gt;This is experimental, and the docs are direct about it: the details of &lt;code&gt;await&lt;/code&gt; handling and &lt;code&gt;$effect.pending()&lt;/code&gt; can change outside a semver major. Effect ordering already shifts when the flag is on, with block effects like &lt;code&gt;{#if}&lt;/code&gt; and &lt;code&gt;{#each}&lt;/code&gt; running before &lt;code&gt;$effect.pre&lt;/code&gt; in the same component.&lt;/p&gt;

&lt;p&gt;Server rendering works through an awaited &lt;code&gt;render(...)&lt;/code&gt;, though frameworks handle that for you. Today a boundary with a &lt;code&gt;pending&lt;/code&gt; snippet renders that snippet during SSR and skips its contents, with streaming planned but not shipped. Svelte 5.42 also added &lt;code&gt;fork(...)&lt;/code&gt;, which speculatively runs async work you expect to need soon, and SvelteKit is the intended consumer for preloading on hover or focus.&lt;/p&gt;

&lt;p&gt;If you maintain a Svelte app, the useful move right now is not a rewrite. Turn the flag on in a branch, pick one component with the most &lt;code&gt;loading&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt; bookkeeping, and see how much of it disappears. The &lt;a href="https://svelte.dev/docs/svelte/await-expressions" rel="noopener noreferrer"&gt;await expressions docs&lt;/a&gt; and the &lt;a href="https://svelte.dev/docs/svelte/svelte-boundary" rel="noopener noreferrer"&gt;&lt;code&gt;&amp;lt;svelte:boundary&amp;gt;&lt;/code&gt; reference&lt;/a&gt; are short enough to read in one sitting.&lt;/p&gt;

</description>
      <category>svelte</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>The Web Locks API: One Tab Does the Work, the Rest Wait</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Sat, 29 Aug 2026 08:06:30 +0000</pubDate>
      <link>https://dev.to/grimicorn/the-web-locks-api-one-tab-does-the-work-the-rest-wait-12om</link>
      <guid>https://dev.to/grimicorn/the-web-locks-api-one-tab-does-the-work-the-rest-wait-12om</guid>
      <description>&lt;p&gt;A user opens your dashboard, then opens it again in a second tab, then leaves a third one parked on another monitor from yesterday. Their access token expires. All three tabs notice at roughly the same instant, and all three fire a refresh request against your auth endpoint. Two of them get back a rotated refresh token that the third has already invalidated, and now the user is staring at a login screen they did nothing to deserve.&lt;/p&gt;

&lt;p&gt;The usual fix is a pile of &lt;code&gt;localStorage&lt;/code&gt; flags with timestamps, a &lt;code&gt;BroadcastChannel&lt;/code&gt; message, and a comment that says &lt;code&gt;// TODO: this is racy&lt;/code&gt;. It is racy. &lt;code&gt;localStorage&lt;/code&gt; has no atomic compare-and-set, so two tabs can read "no refresh in progress" in the same tick and both write "refresh in progress." What you actually want is a mutex, and the browser has shipped one since March 2022. It is called the Web Locks API, it is Baseline Widely available, and almost nobody reaches for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  navigator.locks.request is the whole API
&lt;/h2&gt;

&lt;p&gt;There is one method that matters. You give it a name, a callback, and the browser guarantees that no other code on the same origin — any tab, any iframe, any worker — runs inside a lock with that name at the same time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token-refresh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isExpired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// someone else already did it&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/auth/refresh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;writeToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lock is held for exactly as long as the callback's promise is pending, and it is released when the callback returns or throws. There is no &lt;code&gt;unlock()&lt;/code&gt; to forget, and there is no leaked lock if your fetch rejects. That alone makes it safer than any flag-in-storage scheme you would write by hand.&lt;/p&gt;

&lt;p&gt;The re-check inside the callback is the part people skip. Three tabs queue on &lt;code&gt;'token-refresh'&lt;/code&gt;. The first one does the network round trip. The second and third get the lock afterward, see a token that is no longer expired, and return immediately. One request, three happy tabs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared locks, and not waiting at all
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;request()&lt;/code&gt; takes an options object, and two of the options carry most of the value.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mode: 'shared'&lt;/code&gt; gives you the readers-writer pattern. Any number of shared holders can hold the same name at once, but an exclusive holder blocks all of them. This is the same semantics IndexedDB uses for &lt;code&gt;readonly&lt;/code&gt; versus &lt;code&gt;readwrite&lt;/code&gt; transactions, and it is the right shape when many tabs read a cached dataset while one occasionally rewrites it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Many of these can run concurrently.&lt;/span&gt;
&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;catalog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shared&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;readCatalogFromIDB&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// This one waits for every reader to finish, then blocks new ones.&lt;/span&gt;
&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;catalog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exclusive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rewriteCatalogFromIDB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchCatalog&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ifAvailable: true&lt;/code&gt; flips the behavior from "wait your turn" to "tell me no." The callback still runs, but it receives &lt;code&gt;null&lt;/code&gt; instead of a &lt;code&gt;Lock&lt;/code&gt; when the lock was already held. That is the leader-election primitive: whichever tab gets the lock becomes the one that owns the WebSocket, or the polling interval, or the background sync, and the others quietly stand down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sync-leader&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ifAvailable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// another tab is the leader&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="c1"&gt;// hold it for the lifetime of this tab&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That never-resolving promise looks alarming and is actually the idiom. The lock is held until the tab closes or navigates, at which point the browser releases it and a queued tab is promoted automatically. You get failover for free.&lt;/p&gt;

&lt;p&gt;There is also &lt;code&gt;signal&lt;/code&gt;, which takes an &lt;code&gt;AbortSignal&lt;/code&gt; so you can give up after 200ms instead of queueing forever, and &lt;code&gt;steal: true&lt;/code&gt;, which forcibly releases whoever holds the lock. Treat &lt;code&gt;steal&lt;/code&gt; as a recovery tool for a wedged tab, not a normal control flow — the stolen-from code keeps running and has no idea it lost the lock. Note that &lt;code&gt;signal&lt;/code&gt; cannot be combined with &lt;code&gt;steal&lt;/code&gt; or &lt;code&gt;ifAvailable&lt;/code&gt;; the request rejects with a &lt;code&gt;NotSupportedError&lt;/code&gt; if you try.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edges worth knowing
&lt;/h2&gt;

&lt;p&gt;Locks are scoped per origin and require a secure context, so &lt;code&gt;https://&lt;/code&gt; or &lt;code&gt;localhost&lt;/code&gt; only. They do not survive a reload — every lock a document holds is released when that document goes away, which is the behavior you want but also means a lock is never a durable record of anything. Store the actual state in IndexedDB and use the lock only to serialize who writes it.&lt;/p&gt;

&lt;p&gt;Deadlock is still your problem. If tab A holds &lt;code&gt;a&lt;/code&gt; and waits on &lt;code&gt;b&lt;/code&gt; while tab B does the reverse, they both wait forever. Acquire locks in a consistent order, keep the critical section short, and avoid nesting &lt;code&gt;request()&lt;/code&gt; calls when you can flatten them.&lt;/p&gt;

&lt;p&gt;For debugging, &lt;code&gt;navigator.locks.query()&lt;/code&gt; returns &lt;code&gt;{ held, pending }&lt;/code&gt; arrays with the name, mode, and a &lt;code&gt;clientId&lt;/code&gt; for each. Logging that when something feels stuck is usually faster than reasoning about it.&lt;/p&gt;

&lt;p&gt;If your app has any of the classic multi-tab bugs — duplicated token refreshes, four WebSockets where you wanted one, an IndexedDB migration that runs twice — this is a smaller fix than the workaround you are currently maintaining. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/LockManager/request" rel="noopener noreferrer"&gt;MDN's LockManager reference&lt;/a&gt; covers every option in a page you can read in five minutes.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webapis</category>
      <category>performance</category>
    </item>
    <item>
      <title>Form-Associated Custom Elements: Web Components That Belong in a Form</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Sat, 29 Aug 2026 08:06:24 +0000</pubDate>
      <link>https://dev.to/grimicorn/form-associated-custom-elements-web-components-that-belong-in-a-form-19kd</link>
      <guid>https://dev.to/grimicorn/form-associated-custom-elements-web-components-that-belong-in-a-form-19kd</guid>
      <description>&lt;p&gt;Custom elements have been shippable for years, but the illusion falls apart the moment you drop one inside a &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;. The value never shows up in &lt;code&gt;FormData&lt;/code&gt;. &lt;code&gt;required&lt;/code&gt; does nothing. Hitting reset leaves your control sitting there with stale state, and the browser's validation bubble refuses to point at it. So most of us reach for the same workaround: render a hidden &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt; inside the component and keep it in sync by hand, forever.&lt;/p&gt;

&lt;p&gt;That workaround has been unnecessary for a while now. Form-associated custom elements are Baseline — Chromium, Firefox, and Safari 16.4 and up — and they let a component participate in a form as a first-class control instead of a decoration sitting next to one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two lines make it a form control
&lt;/h2&gt;

&lt;p&gt;The whole thing hinges on a static property and one method call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RatingInput&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;HTMLElement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;formAssociated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;internals&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;internals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachInternals&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachShadow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;connectedCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
      &amp;lt;div role="radiogroup" aria-label="Rating"&amp;gt;
        &lt;/span&gt;&lt;span class="p"&gt;${[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
              &lt;span class="s2"&gt;`&amp;lt;button part="star" type="button" value="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;&amp;amp;#9733;&amp;lt;/button&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
      &amp;lt;/div&amp;gt;
    `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="nf"&gt;value&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;set&lt;/span&gt; &lt;span class="nf"&gt;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;internals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFormValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;customElements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rating-input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RatingInput&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;static formAssociated = true&lt;/code&gt; tells the browser to treat the element like a form control: it gets picked up by the owning form, it inherits &lt;code&gt;name&lt;/code&gt;, and it becomes eligible for validation. &lt;code&gt;attachInternals()&lt;/code&gt; hands back an &lt;code&gt;ElementInternals&lt;/code&gt; object, which is the private channel your component uses to talk to the form. Guard it — anything you can do through internals is something you probably do not want page scripts doing on your behalf, which is why it lives in a private field.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setFormValue()&lt;/code&gt; is the part that ends the hidden-input era. Pass it a string, a &lt;code&gt;File&lt;/code&gt;, or a whole &lt;code&gt;FormData&lt;/code&gt; object when one control needs to contribute several named values, and it lands in the submission:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"review"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;rating-input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"score"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/rating-input&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&amp;gt;&lt;/span&gt;Submit&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;score&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The lifecycle you get for free
&lt;/h2&gt;

&lt;p&gt;Being form-associated also opts you into callbacks the browser fires at the right moments, so you stop wiring up listeners for things the platform already knows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;formResetCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;formDisabledCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toggleAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;formStateRestoreCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;formResetCallback&lt;/code&gt; runs on &lt;code&gt;form.reset()&lt;/code&gt;. &lt;code&gt;formDisabledCallback&lt;/code&gt; fires when the element or its enclosing &lt;code&gt;&amp;lt;fieldset&amp;gt;&lt;/code&gt; gets disabled, which is the case almost everyone forgets. &lt;code&gt;formStateRestoreCallback&lt;/code&gt; is the one that quietly wins arguments in code review: it restores state on back-navigation and session restore, using the optional second argument to &lt;code&gt;setFormValue(value, state)&lt;/code&gt;. If your control's submission value differs from what the user actually typed — a formatted currency field, say — pass the raw input as that second argument and you get real state restoration instead of an empty box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation the browser actually understands
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;setValidity()&lt;/code&gt; is where custom controls finally stop being second-class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;empty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;internals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValidity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;empty&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valueMissing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="nx"&gt;empty&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Please choose a rating.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first argument is a &lt;code&gt;ValidityStateFlags&lt;/code&gt; dictionary using the same flag names as native inputs (&lt;code&gt;valueMissing&lt;/code&gt;, &lt;code&gt;rangeUnderflow&lt;/code&gt;, &lt;code&gt;customError&lt;/code&gt;, and so on). The second is the message. The third — the anchor — is the one people skip and then wonder why nothing appears: it is the element the browser points its validation bubble at. Without an anchor inside your shadow root, Chromium has nowhere to render the message and silently gives up.&lt;/p&gt;

&lt;p&gt;Get this right and &lt;code&gt;form.reportValidity()&lt;/code&gt;, implicit submit blocking, and the &lt;code&gt;:invalid&lt;/code&gt; pseudo-class all work against your component exactly as they do against &lt;code&gt;&amp;lt;input required&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Styling states without attribute soup
&lt;/h2&gt;

&lt;p&gt;The same &lt;code&gt;ElementInternals&lt;/code&gt; object carries a &lt;code&gt;states&lt;/code&gt; set, so internal state no longer has to leak out as a reflected attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;internals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;states&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;internals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;states&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;rating-input&lt;/span&gt;&lt;span class="nd"&gt;:state&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;rated&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="nd"&gt;::part&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;star&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;gold&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;:state()&lt;/code&gt; has been Baseline since 2024 and composes with &lt;code&gt;:host()&lt;/code&gt; and &lt;code&gt;::part()&lt;/code&gt;, which means consumers can style your component's states without you publishing a contract of magic class names.&lt;/p&gt;

&lt;p&gt;None of this is new enough to be risky anymore, and it collapses a surprising amount of glue code. Next time you are about to add a hidden input to a component, open the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals" rel="noopener noreferrer"&gt;&lt;code&gt;ElementInternals&lt;/code&gt; docs on MDN&lt;/a&gt; instead and delete it before it exists.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>a11y</category>
      <category>css</category>
      <category>webapis</category>
    </item>
    <item>
      <title>Next.js Partial Prefetching: One Shell Per Route, Not One Per Link</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Mon, 24 Aug 2026 08:06:05 +0000</pubDate>
      <link>https://dev.to/grimicorn/nextjs-partial-prefetching-one-shell-per-route-not-one-per-link-27nj</link>
      <guid>https://dev.to/grimicorn/nextjs-partial-prefetching-one-shell-per-route-not-one-per-link-27nj</guid>
      <description>&lt;p&gt;Open the Network tab on a production Next.js app and scroll a page with a long list of links. You get a waterfall of prefetch requests, one per link, most of them hitting the same route with different params. A sidebar with twenty chat threads fires twenty requests to render twenty variations of the same &lt;code&gt;/chat/[id]&lt;/code&gt; page. The Next.js team's own writeup calls this "ridiculous," which is refreshingly blunt for a framework changelog.&lt;/p&gt;

&lt;p&gt;Next.js 16.3 replaces that model. Instead of prefetching a page per link, it prefetches a reusable shell per route and caches it on the client for the session. Vercel calls this Partial Prefetching, and it ships as part of a bundle of opt-in behaviors called Instant Navigations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gaps, two different fixes
&lt;/h2&gt;

&lt;p&gt;A navigation feels slow for two independent reasons. The client has to talk to the server, which costs a network roundtrip. And the server has to actually generate a response, which costs however long your data layer takes.&lt;/p&gt;

&lt;p&gt;Server Components fixed a lot of things but made this worse in one specific way: click a link, nothing happens, then the whole page appears. That's the server-generation gap. Under Cache Components, Next.js now forces you to make an explicit choice for every route that awaits data. Stream it with &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; so the user sees a loading state immediately. Cache it with &lt;code&gt;'use cache'&lt;/code&gt; so the user sees previously rendered UI immediately. Or deliberately opt out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/posts/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;instant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is a real option, not a failure state. A blog post route may genuinely be better off blocking than flashing a skeleton for 80ms. Marking it &lt;code&gt;instant = false&lt;/code&gt; tells the framework you meant it, and the dev-mode warning goes away.&lt;/p&gt;

&lt;p&gt;Partial Prefetching handles the other gap: the network hop. If the shell for &lt;code&gt;/chat/[id]&lt;/code&gt; is already sitting in the client cache before you click, there is no roundtrip at click time at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it on
&lt;/h2&gt;

&lt;p&gt;Both behaviors are gated behind config flags in 16.3, and both are slated to become defaults in a future major:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cacheComponents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;partialPrefetching&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cacheComponents&lt;/code&gt; is the prerequisite. It's the flag that turns on the &lt;code&gt;'use cache'&lt;/code&gt; model and, with it, the dynamic-by-default behavior with no implicit caching. If you're upgrading an existing app, expect this to surface routes you didn't know were blocking. That's the point. The new Instant Insights panel in DevTools lists every navigation that isn't instant, so you work a queue instead of guessing.&lt;/p&gt;

&lt;p&gt;There's also a Navigation Inspector that pauses a navigation at the shell so you can see exactly what a user would see mid-load. This matters more than it sounds, because prefetching is disabled in development, which historically made loading states nearly impossible to eyeball.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prefetching more than the shell
&lt;/h2&gt;

&lt;p&gt;The shell is the new baseline, and it is deliberately small. Sometimes that's not enough. If you want a chat header or a product title to pop in instantly rather than stream, opt that specific link into deeper prefetching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/chat/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;prefetch&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even then, Next.js won't try to render the whole route. It renders down to whatever is available synchronously, derivable from the URL params or search params, or marked &lt;code&gt;'use cache'&lt;/code&gt;. So &lt;code&gt;prefetch={true}&lt;/code&gt; is no longer the all-or-nothing hammer it used to be. Pair it with &lt;code&gt;'use cache'&lt;/code&gt; on the components you want warm, and you get a graded response instead of a binary one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it from rotting
&lt;/h2&gt;

&lt;p&gt;The failure mode here is subtle. A route navigates instantly today. Six weeks from now someone adds a &lt;code&gt;cookies()&lt;/code&gt; read to a shared header, the route de-opts to request-time rendering, and the instant UI quietly disappears. Nothing errors. Nothing fails CI.&lt;/p&gt;

&lt;p&gt;16.3 ships an &lt;code&gt;instant()&lt;/code&gt; Playwright helper that asserts what must be visible without waiting on the network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;instant&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@next/playwright&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;product title is available immediately&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/products/shoes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a[href="/products/hats"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;h1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toContainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Baseball Cap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Checking inventory...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;12 in stock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write one of these for the two or three navigations users actually notice and you've turned a perception problem into a test failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth trying now
&lt;/h2&gt;

&lt;p&gt;If you're already on 16.x, &lt;code&gt;npm install next@latest&lt;/code&gt; gets you the non-flagged wins for free: up to 90% less dev-server memory, cached repeat builds, and roughly 22% more requests handled under load from swapping web streams for native Node streams. None of that requires touching your code.&lt;/p&gt;

&lt;p&gt;The flags are the bigger commitment. Turn on &lt;code&gt;cacheComponents&lt;/code&gt; in a branch, let Instant Insights tell you which routes are blocking, and decide per route whether you want to stream, cache, or block. The answer won't be the same for all of them, and that's the improvement.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>performance</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Declarative Partial Updates: Out-of-Order HTML Streaming Without a Framework</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Sat, 22 Aug 2026 08:06:02 +0000</pubDate>
      <link>https://dev.to/grimicorn/declarative-partial-updates-out-of-order-html-streaming-without-a-framework-1086</link>
      <guid>https://dev.to/grimicorn/declarative-partial-updates-out-of-order-html-streaming-without-a-framework-1086</guid>
      <description>&lt;p&gt;HTML has one stubborn rule that has quietly shaped a decade of frontend architecture: it renders in the order it arrives. If the third section of your page needs a slow database query, everything after it waits. The usual escape hatches are all compromises. You buffer the whole response and give up streaming entirely, you reorder with CSS and break the accessibility tree, or you ship a framework whose main job is turning that server delay into a client-side spinner.&lt;/p&gt;

&lt;p&gt;Chrome 148 has an experimental answer that skips all three. Under the umbrella name &lt;strong&gt;Declarative Partial Updates&lt;/strong&gt;, two related APIs let the server send a placeholder now and fill it in later, and let JavaScript stream markup into an element instead of waiting for the full string. They are behind &lt;code&gt;chrome://flags/#enable-experimental-web-platform-features&lt;/code&gt; today, with polyfills on npm and positive noises from other vendors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Placeholders you fill in later
&lt;/h2&gt;

&lt;p&gt;The declarative half revives something HTML has ignored for its entire life: processing instructions. In XML they carry metadata; in HTML they have always been parsed as comments and thrown away. The new API gives them a job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="na"&gt;marker&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"user-panel"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- ...the rest of the page streams... --&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;template&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"user-panel"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; Welcome back, &lt;span class="nt"&gt;&amp;lt;strong&amp;gt;&lt;/span&gt;Dan&lt;span class="nt"&gt;&amp;lt;/strong&amp;gt;&lt;/span&gt;. &lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the parser reaches &lt;code&gt;&amp;lt;template for="user-panel"&amp;gt;&lt;/code&gt;, it finds the matching &lt;code&gt;&amp;lt;?marker&amp;gt;&lt;/code&gt; and swaps its own content in. The DOM you end up with contains no marker and no template, just the paragraph. The server never had to hold back the rest of the document while it waited on that user lookup.&lt;/p&gt;

&lt;p&gt;There is a range form too, which is the one you will reach for most, because it gives you a loading state for free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;ul&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"results"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"results"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"skeleton"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Loading…&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything between &lt;code&gt;&amp;lt;?start&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;?end&amp;gt;&lt;/code&gt; renders immediately and gets replaced when the template shows up. Better still, a template can re-emit a marker, which turns this into an append loop. Stream one &lt;code&gt;&amp;lt;template for="results"&amp;gt;&lt;/code&gt; per row as your query yields them, each ending with &lt;code&gt;&amp;lt;?marker name="results"&amp;gt;&lt;/code&gt;, and the list grows in place. No &lt;code&gt;appendChild&lt;/code&gt;, no framework, no client-side JavaScript at all.&lt;/p&gt;

&lt;p&gt;The scoping rule is the important restriction: a &lt;code&gt;&amp;lt;template for&amp;gt;&lt;/code&gt; can only patch markers inside its own parent element. That is deliberate, and it means a template dropped into &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; has reach over the entire document including &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. Worth knowing before you generate one from user input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JavaScript side got a rewrite too
&lt;/h2&gt;

&lt;p&gt;The second half addresses a mess most of us have stopped noticing. Ask yourself, honestly, which of &lt;code&gt;innerHTML&lt;/code&gt;, &lt;code&gt;setHTML&lt;/code&gt;, &lt;code&gt;setHTMLUnsafe&lt;/code&gt;, &lt;code&gt;insertAdjacentHTML&lt;/code&gt;, and &lt;code&gt;createContextualFragment&lt;/code&gt; sanitize their input, which run &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags, and which respect Trusted Types. Nobody remembers, because the answers were never consistent.&lt;/p&gt;

&lt;p&gt;The proposal replaces that with a grid you can actually reason about. Six positions, each with a static and a streaming form:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Static&lt;/th&gt;
&lt;th&gt;Streaming&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Replace contents&lt;/td&gt;
&lt;td&gt;&lt;code&gt;setHTML()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;streamHTML()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replace the element itself&lt;/td&gt;
&lt;td&gt;&lt;code&gt;replaceWithHTML()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;streamReplaceWithHTML()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert as first child&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prependHTML()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;streamPrependHTML()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert as last child&lt;/td&gt;
&lt;td&gt;&lt;code&gt;appendHTML()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;streamAppendHTML()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert before / after&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;beforeHTML()&lt;/code&gt; / &lt;code&gt;afterHTML()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;streamBeforeHTML()&lt;/code&gt; / &lt;code&gt;streamAfterHTML()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one has an &lt;code&gt;Unsafe&lt;/code&gt; twin. The naming is the whole point: the plain versions sanitize by default, the &lt;code&gt;Unsafe&lt;/code&gt; versions do not and additionally accept &lt;code&gt;runScripts: true&lt;/code&gt; if you actually want scripts to execute. The word "unsafe" is a speed bump, not a prohibition.&lt;/p&gt;

&lt;p&gt;The streaming versions are the genuinely new capability. They return a &lt;code&gt;WritableStream&lt;/code&gt;, so a fetch response can go straight into the DOM as it arrives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/content.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeThrough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoderStream&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;streamHTMLUnsafe&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the thing SPAs have never been able to do. Initial page loads have always streamed; every client-side route change since has thrown that away and waited for a complete payload before touching the DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually goes
&lt;/h2&gt;

&lt;p&gt;The two halves compose, and that is where it gets interesting. Because &lt;code&gt;streamHTMLUnsafe()&lt;/code&gt; behaves like the main parser, it processes &lt;code&gt;&amp;lt;template for&amp;gt;&lt;/code&gt; instructions as they land. So a client-side route change can be an outline page full of markers plus a stream of templates slotting into them, with no per-element &lt;code&gt;querySelector&lt;/code&gt; bookkeeping. That is a surprising amount of a component framework, expressed in markup.&lt;/p&gt;

&lt;p&gt;Temper expectations on timing. This is one engine, behind a flag, and the sanitizer that &lt;code&gt;setHTML&lt;/code&gt; depends on is still missing in Safari. The two polyfills (&lt;code&gt;template-for-polyfill&lt;/code&gt; and &lt;code&gt;html-setters-polyfill&lt;/code&gt;) are worth a spike, but read the fine print: the setters polyfill buffers rather than streams, so it gives you the API shape without the performance win. Treat it as a preview of where the platform is heading, not something to put in front of users this quarter.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://developer.chrome.com/blog/declarative-partial-updates" rel="noopener noreferrer"&gt;Declarative partial updates (Chrome for Developers)&lt;/a&gt; and the &lt;a href="https://github.com/WICG/declarative-partial-updates" rel="noopener noreferrer"&gt;WICG explainer&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webapis</category>
      <category>performance</category>
      <category>frontend</category>
    </item>
    <item>
      <title>React's Activity Component: Hide UI Without Losing Its State</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Tue, 18 Aug 2026 08:05:29 +0000</pubDate>
      <link>https://dev.to/grimicorn/reacts-activity-component-hide-ui-without-losing-its-state-jem</link>
      <guid>https://dev.to/grimicorn/reacts-activity-component-hide-ui-without-losing-its-state-jem</guid>
      <description>&lt;p&gt;A user types half a message into the compose tab, flips over to the settings tab to change a&lt;br&gt;
notification preference, flips back, and the draft is gone. You know exactly why: &lt;code&gt;{tab === 'compose' &amp;amp;&amp;amp; &amp;lt;Compose /&amp;gt;}&lt;/code&gt; unmounted the subtree, and unmounting throws away state.&lt;/p&gt;

&lt;p&gt;The usual fixes are all a little sad. Lift the state up and thread it back down through props.&lt;br&gt;
Park it in a store that exists only to survive an unmount. Or render everything at once behind a&lt;br&gt;
&lt;code&gt;display: none&lt;/code&gt; class and eat the mount cost of every panel on first paint. React 19.2 added a&lt;br&gt;
first-class answer instead: &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two modes and a boundary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Activity&lt;/code&gt; is a component you import from &lt;code&gt;react&lt;/code&gt; directly. It takes a &lt;code&gt;mode&lt;/code&gt; prop that is either&lt;br&gt;
&lt;code&gt;visible&lt;/code&gt; or &lt;code&gt;hidden&lt;/code&gt;, and it wraps the subtree you want to keep alive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Activity&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Workspace&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compose&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visible&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Compose&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;settings&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visible&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Settings&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a boundary goes hidden, React hides its children with &lt;code&gt;display: none&lt;/code&gt; rather than removing&lt;br&gt;
them. Every &lt;code&gt;useState&lt;/code&gt; and &lt;code&gt;useReducer&lt;/code&gt; value in the subtree is preserved, and so is the DOM state&lt;br&gt;
that React normally has no opinion about: scroll offsets, uncontrolled input values, the current&lt;br&gt;
playback position of a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;. Flip back to &lt;code&gt;visible&lt;/code&gt; and the panel is exactly where the user&lt;br&gt;
left it, with no restoration logic on your side.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hidden does not mean paused
&lt;/h2&gt;

&lt;p&gt;This is the part that trips people up, so it's worth being blunt about it: hiding an &lt;code&gt;Activity&lt;/code&gt;&lt;br&gt;
runs your cleanup functions. Every &lt;code&gt;useEffect&lt;/code&gt; and &lt;code&gt;useLayoutEffect&lt;/code&gt; cleanup in the subtree fires,&lt;br&gt;
exactly as if the component had unmounted. Sockets close, intervals clear, observers disconnect.&lt;br&gt;
When the boundary becomes visible again, the setup functions run again.&lt;/p&gt;

&lt;p&gt;That is the behavior you want most of the time. A hidden panel holding an open WebSocket is a&lt;br&gt;
resource leak with extra steps.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LivePrices&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;symbol&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;quotes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQuotes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;({});&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`wss://example.com/quotes/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setQuotes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;QuoteTable&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;quotes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapped in a hidden &lt;code&gt;Activity&lt;/code&gt;, the socket closes but &lt;code&gt;quotes&lt;/code&gt; survives. Come back and the table&lt;br&gt;
paints immediately with the last known numbers, then updates as fresh messages arrive. The user&lt;br&gt;
sees stale-but-plausible data instead of a spinner. The practical requirement is that your Effects&lt;br&gt;
have to tolerate running more than once, which is the same discipline StrictMode's double-invoke&lt;br&gt;
has been enforcing in development for years.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pre-rendering what nobody has clicked yet
&lt;/h2&gt;

&lt;p&gt;Hidden boundaries are not inert. React still renders them, at the lowest priority it has. That&lt;br&gt;
turns &lt;code&gt;Activity&lt;/code&gt; into a way to warm up a route before the user asks for it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/reports&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visible&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportsPage&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reports page mounts, its data fetches start, its component tree gets built, all in the gaps&lt;br&gt;
between higher-priority work. Navigation then feels instant because most of the work already&lt;br&gt;
happened.&lt;/p&gt;

&lt;p&gt;The catch: lowest priority is the only priority you get. There is no knob for tuning it, and if&lt;br&gt;
the hidden subtree is genuinely expensive it still competes for the same main thread as everything&lt;br&gt;
visible. &lt;code&gt;Activity&lt;/code&gt; reorders work; it does not make it free. React 19.2's DevTools Performance&lt;br&gt;
Tracks are the right place to check whether a background boundary is actually paying for itself.&lt;/p&gt;

&lt;p&gt;The other cost is DOM weight. Hidden children remain in the document, so twenty hidden panels are&lt;br&gt;
twenty panels' worth of nodes that the browser still has to keep in memory and account for in style&lt;br&gt;
recalculation. &lt;code&gt;Activity&lt;/code&gt; is aimed at a handful of heavy, stateful regions, such as tab groups,&lt;br&gt;
wizard steps, and a route you're fairly confident is next. It is not a blanket replacement for&lt;br&gt;
conditional rendering, and a list of a thousand rows should still unmount.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it retires
&lt;/h2&gt;

&lt;p&gt;If you've written a &lt;code&gt;useRef&lt;/code&gt; cache to stash a component's scroll position, or added a slice to&lt;br&gt;
Zustand whose only job was surviving an unmount, or hand-rolled a &lt;code&gt;hidden&lt;/code&gt; class plus a pile of&lt;br&gt;
&lt;code&gt;if (!visible) return&lt;/code&gt; guards inside your Effects, that's the pattern &lt;code&gt;Activity&lt;/code&gt; collapses into one&lt;br&gt;
boundary.&lt;/p&gt;

&lt;p&gt;It shipped stable in React 19.2, so there's no canary flag to opt into. Pick the tab group in your&lt;br&gt;
app that annoys you most, wrap each panel, and delete the state-preservation scaffolding you built&lt;br&gt;
around it. The &lt;a href="https://react.dev/reference/react/Activity" rel="noopener noreferrer"&gt;official reference&lt;/a&gt; covers the&lt;br&gt;
remaining edge cases, including how it interacts with Suspense.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>revalidateTag vs updateTag: Next.js Split Cache Invalidation in Two</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Mon, 17 Aug 2026 08:05:42 +0000</pubDate>
      <link>https://dev.to/grimicorn/revalidatetag-vs-updatetag-nextjs-split-cache-invalidation-in-two-bag</link>
      <guid>https://dev.to/grimicorn/revalidatetag-vs-updatetag-nextjs-split-cache-invalidation-in-two-bag</guid>
      <description>&lt;p&gt;There is a bug I have written at least three times. An editor updates a product description in the CMS, hits publish, refreshes the page, and sees the old copy. So I add an on-demand revalidation webhook. Then a logged-in user submits a form, gets redirected to the detail page, and sees their own submission missing. Same cache, same invalidation call, two completely different expectations about what "invalidate" means.&lt;/p&gt;

&lt;p&gt;Next.js 16 stopped pretending those are the same operation. &lt;code&gt;revalidateTag&lt;/code&gt; and &lt;code&gt;updateTag&lt;/code&gt; now exist side by side, and the difference is not a naming quirk. One serves stale content while it rebuilds. The other blocks until the data is fresh. Picking the wrong one is how you end up with the bug above.&lt;/p&gt;

&lt;h2&gt;
  
  
  The staleness question decides which function you call
&lt;/h2&gt;

&lt;p&gt;Both functions operate on cache tags, so the setup is identical. You tag cached data either through &lt;code&gt;fetch&lt;/code&gt; or inside a &lt;code&gt;'use cache'&lt;/code&gt; function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cacheLife&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://cms.example.com/products/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cacheLife('max')&lt;/code&gt; says: do not bother with time-based expiry, this thing stays cached until something explicitly tells us it changed. That is the right posture for CMS content. Nobody wants a revalidate-every-60-seconds timer hammering an API that changes twice a week.&lt;/p&gt;

&lt;p&gt;Now the invalidation. From a webhook, you want &lt;code&gt;revalidateTag&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/cms-webhook/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-webhook-secret&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CMS_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;product&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;revalidated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second argument matters. &lt;code&gt;revalidateTag(tag, 'max')&lt;/code&gt; marks the entry stale rather than expiring it, so the next visitor gets the cached page instantly while a fresh render happens in the background. The single-argument form still works but is deprecated, and it is the blocking version, which means the unlucky first visitor after every content edit eats a full server render.&lt;/p&gt;

&lt;p&gt;There is a subtlety worth knowing: marking a tag stale does not trigger a rebuild. Nothing regenerates until somebody actually visits a page using that tag. If you invalidate a tag attached to ten thousand product pages, you do not get ten thousand simultaneous renders. You get renders spread across real traffic, which is usually what you want and occasionally surprising if you were expecting a burst of activity in your logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  updateTag is for read-your-own-writes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;updateTag&lt;/code&gt; only works inside Server Actions. Not Route Handlers, not Client Components. That restriction is the whole point: Server Actions are where a user does something and then immediately looks at the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;updateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redirect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;review&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;productId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;updateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reviews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;updateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/products/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;review&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;updateTag&lt;/code&gt; expires the entry outright. The redirect that follows will wait for a fresh render rather than handing back the version of the page that does not have the review on it. Slower, and correct. Swap in &lt;code&gt;revalidateTag(tag, 'max')&lt;/code&gt; here and the user lands on a page missing the thing they just wrote, which reads as a broken form even though the write succeeded.&lt;/p&gt;

&lt;p&gt;The rule I have settled on: if a human is waiting to see their own change, &lt;code&gt;updateTag&lt;/code&gt;. If a system somewhere told you data changed, &lt;code&gt;revalidateTag&lt;/code&gt; with &lt;code&gt;'max'&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-instance is where this quietly breaks
&lt;/h2&gt;

&lt;p&gt;One detail the docs are refreshingly blunt about: revalidation events are local by default. Call &lt;code&gt;revalidateTag&lt;/code&gt; on the instance that received the webhook and only that instance's cache is invalidated. Every other instance behind your load balancer keeps serving the old page until its own copy expires.&lt;/p&gt;

&lt;p&gt;On a single-instance deploy or a platform that handles this for you, it never comes up. Run three containers yourself and you get a genuinely confusing bug where refreshing the page flips between old and new content depending on routing. The fix is a custom cache handler implementing &lt;code&gt;updateTags()&lt;/code&gt; to write invalidation timestamps to shared storage and &lt;code&gt;refreshTags()&lt;/code&gt; to read them back before each request. Wrap &lt;code&gt;refreshTags()&lt;/code&gt; in a try/catch, because a thrown error there propagates as a request failure rather than degrading to stale content.&lt;/p&gt;

&lt;p&gt;Worth auditing your own setup before you need it. Cache invalidation being hard is a cliché, but the specific hard part here is that the failure mode is invisible in development and intermittent in production.&lt;/p&gt;

&lt;p&gt;If you are still on the single-argument &lt;code&gt;revalidateTag&lt;/code&gt;, that migration is the cheapest win available: add &lt;code&gt;'max'&lt;/code&gt; where a webhook fires it, switch to &lt;code&gt;updateTag&lt;/code&gt; where a Server Action does.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>react</category>
      <category>performance</category>
      <category>webapis</category>
    </item>
    <item>
      <title>CSS text-box-trim: The End of Fudging Vertical Padding</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Fri, 14 Aug 2026 08:05:34 +0000</pubDate>
      <link>https://dev.to/grimicorn/css-text-box-trim-the-end-of-fudging-vertical-padding-4d92</link>
      <guid>https://dev.to/grimicorn/css-text-box-trim-the-end-of-fudging-vertical-padding-4d92</guid>
      <description>&lt;p&gt;You give a button &lt;code&gt;padding: 12px&lt;/code&gt; and it comes out looking bottom-heavy. So you split it: &lt;code&gt;padding: 10px 12px 14px&lt;/code&gt;, squint, nudge the numbers, ship it. Two sprints later design swaps the type family and every one of those hand-tuned values is wrong again, because the new font reserves a different amount of invisible space than the old one did.&lt;/p&gt;

&lt;p&gt;That invisible space has a name, and as of this month you can finally cut it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the extra space comes from
&lt;/h2&gt;

&lt;p&gt;Every font ships with metrics that describe more than the letters you can see. There's room above the capitals for accents and diacritics, and room below the baseline for descenders, and the browser reserves all of it whether or not a single "p" appears in your text. On top of that, &lt;code&gt;line-height&lt;/code&gt; adds leading, which the web splits in half and distributes evenly above and below the content area. Matthias Ott's &lt;a href="https://matthiasott.com/notes/the-thing-with-leading-in-css" rel="noopener noreferrer"&gt;The Thing With Leading In CSS&lt;/a&gt; walks through the typesetting history behind that split, but the practical upshot is simple: a text box is always taller than its text, by an amount that changes per font and per &lt;code&gt;line-height&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Symmetric padding on top of an asymmetric box gives you an asymmetric-looking result. That's the whole bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two properties
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;text-box-trim&lt;/code&gt; says which edges to cut. &lt;code&gt;text-box-edge&lt;/code&gt; says where to cut to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;text-box-trim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trim-both&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* trim-start | trim-end | trim-both | none */&lt;/span&gt;
  &lt;span class="py"&gt;text-box-edge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="n"&gt;alphabetic&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* over-edge, then under-edge */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cap&lt;/code&gt; trims the over edge down to the top of the capital letters. &lt;code&gt;ex&lt;/code&gt; trims to the x-height instead, which is a nicer optical match for some display faces. &lt;code&gt;alphabetic&lt;/code&gt; trims the under edge flush with the baseline. There's a &lt;code&gt;text&lt;/code&gt; value on both sides if you want the font's own text edges rather than the letterform edges.&lt;/p&gt;

&lt;p&gt;In practice you'll write the shorthand and move on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;text-box&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trim-both&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="n"&gt;alphabetic&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the version you want roughly all of the time. Now &lt;code&gt;padding: 12px&lt;/code&gt; actually means twelve pixels of visible space on every side, in every font, and swapping the type family doesn't silently re-break your spacing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually pays off
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Optical centering.&lt;/strong&gt; Buttons, badges, pill-shaped tags, anything small and intrinsically sized. These are the components where half-leading is proportionally largest and most obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aligning text next to non-text.&lt;/strong&gt; Put a 40px avatar beside a heading and the heading's box is taller than its letters, so the two never quite line up. Trim the heading and they do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gaps that mean something.&lt;/strong&gt; In a stacked block of text, &lt;code&gt;gap&lt;/code&gt; and &lt;code&gt;margin-block&lt;/code&gt; measure box edges, which include the invisible band on both sides. Trim the leading and your rhythm values start describing the space a reader actually perceives, instead of that space minus an unknown font-dependent constant.&lt;/p&gt;

&lt;p&gt;One caveat worth being deliberate about: trimming removes &lt;em&gt;visual&lt;/em&gt; space, not just visual space you didn't want. If you trim a button and don't add the padding back, you've quietly shrunk the tap target. WCAG 2.2's target size minimum is 24 by 24 CSS pixels, and half-leading was doing invisible work toward that number. Trim, then set the padding you actually meant.&lt;/p&gt;

&lt;p&gt;Multi-line text behaves the way you'd hope: the trim applies above the first formatted line and below the last one, never between lines. And because it's defined in terms of over and under rather than top and bottom, it follows &lt;code&gt;writing-mode&lt;/code&gt; correctly without any extra work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it
&lt;/h2&gt;

&lt;p&gt;Chrome and Edge have had this since 133, Safari since 18.2, and Firefox 154 turns it on by default. That release is dated August 18, 2026, which makes &lt;code&gt;text-box&lt;/code&gt; a Baseline newly-available feature about a week from now.&lt;/p&gt;

&lt;p&gt;Even before that lands everywhere, the failure mode is the kindest one CSS offers: a browser that doesn't recognize the property ignores the declaration and renders the spacing it always did. Nothing collapses, nothing overlaps, you just don't get the tightening. That makes it safe to add today with no fallback at all.&lt;/p&gt;

&lt;p&gt;If the trimmed spacing is load-bearing in a particular component, gate the compensating padding behind a feature query so the two values stay in sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@supports&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text-box&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trim-both&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="n"&gt;alphabetic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;text-box&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trim-both&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="n"&gt;alphabetic&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've ever used Figma's vertical trim control and wondered why the handoff never matched, this is the missing half. Adam Argyle's &lt;a href="https://developer.chrome.com/blog/css-text-box-trim" rel="noopener noreferrer"&gt;Chrome for Developers post&lt;/a&gt; has an interactive playground that's worth ten minutes of poking at with your own type stack, and the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-box-trim" rel="noopener noreferrer"&gt;MDN reference&lt;/a&gt; has the full value grammar. Start with your buttons. The difference is small, and you will not be able to unsee it.&lt;/p&gt;

</description>
      <category>css</category>
      <category>a11y</category>
    </item>
    <item>
      <title>Trusted Types Is Baseline: DOM XSS Is Now a Type Error</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Tue, 11 Aug 2026 08:05:20 +0000</pubDate>
      <link>https://dev.to/grimicorn/trusted-types-is-baseline-dom-xss-is-now-a-type-error-1k1h</link>
      <guid>https://dev.to/grimicorn/trusted-types-is-baseline-dom-xss-is-now-a-type-error-1k1h</guid>
      <description>&lt;p&gt;Every codebase has one. Somewhere in a component nobody has opened in eighteen months, there is a line that reads &lt;code&gt;el.innerHTML = someValue&lt;/code&gt;, and nobody can tell you with confidence where &lt;code&gt;someValue&lt;/code&gt; comes from. Maybe it's a hardcoded template. Maybe it's a server response. Maybe, three refactors ago, it started carrying a slice of &lt;code&gt;location.hash&lt;/code&gt;. That uncertainty is the entire DOM XSS problem: the sink is a plain string setter, strings all look alike, and the browser has no way to tell a trusted one from an attacker-controlled one.&lt;/p&gt;

&lt;p&gt;Trusted Types fixes that by refusing strings outright. And as of February 2026, when Firefox 148 shipped support, it's &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API" rel="noopener noreferrer"&gt;Baseline&lt;/a&gt; — Chrome and Edge have had it since 83 back in 2020, Safari joined in version 26, and now the whole core browser set is covered. It's no longer a Chrome-only hardening trick you bolt onto an internal admin tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning a whole class of bug into a runtime error
&lt;/h2&gt;

&lt;p&gt;The API works by locking down the risky sinks: &lt;code&gt;innerHTML&lt;/code&gt;, &lt;code&gt;outerHTML&lt;/code&gt;, &lt;code&gt;insertAdjacentHTML&lt;/code&gt;, &lt;code&gt;document.write&lt;/code&gt;, &lt;code&gt;DOMParser.parseFromString&lt;/code&gt;, &lt;code&gt;&amp;lt;iframe srcdoc&amp;gt;&lt;/code&gt;, script &lt;code&gt;src&lt;/code&gt; and text content, and the code-compiling family (&lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;new Function()&lt;/code&gt;, string-argument &lt;code&gt;setTimeout&lt;/code&gt; and &lt;code&gt;setInterval&lt;/code&gt;). Once enforcement is on, passing a raw string to any of them throws a &lt;code&gt;TypeError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You opt in with a CSP header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Content-Security-Policy: require-trusted-types-for 'script'; trusted-types escape-html;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That first directive is the switch. The second is an allowlist of policy names — factories that are the only things permitted to mint a trusted value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escapeHTMLPolicy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trustedTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;escape-html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;createHTML&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;escapeHTMLPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;img src=x onerror=alert(1)&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;safe&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TrustedHTML&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;safe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// fine&lt;/span&gt;

&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userInput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// TypeError&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what this actually buys you. Trusted Types does not sanitize anything — your &lt;code&gt;createHTML&lt;/code&gt; function is still your own code and can still be wrong. What it guarantees is that &lt;em&gt;every&lt;/em&gt; path into a dangerous sink now runs through a named policy you declared on purpose. The DOM XSS attack surface of the entire app collapses down to the handful of lines inside your policies. That is a security review you can finish in an afternoon instead of grepping 40,000 lines for &lt;code&gt;innerHTML&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also worth knowing before you start: Trusted Types only works in secure contexts, so HTTPS or &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out without breaking production
&lt;/h2&gt;

&lt;p&gt;Do not flip enforcement on first. Ship the report-only variant, let it run against real traffic, and collect what breaks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-uri /csp-reports
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Violations arrive with the file, line, column, and a &lt;code&gt;script-sample&lt;/code&gt; snippet of the offending value, which is usually enough to find the culprit immediately. If you'd rather not stand up a collector on day one, a &lt;code&gt;ReportingObserver&lt;/code&gt; gets you the same data in the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReportingObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reports&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;reports&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveDirective&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;require-trusted-types-for&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Trusted Types violation:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then work the list. Most violations have a boring fix — the code didn't need string HTML in the first place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;img src=xyz.jpg&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceChildren&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;img&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xyz.jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where you genuinely need to render untrusted HTML, reach for a sanitizer that already speaks the protocol. DOMPurify will hand back a &lt;code&gt;TrustedHTML&lt;/code&gt; instead of a string if you ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dompurify&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;RETURN_TRUSTED_TYPE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The escape hatch is a policy literally named &lt;code&gt;default&lt;/code&gt;, which the browser applies to any string that reaches a sink without one. It's the right tool when a third-party script from a CDN is the thing violating and you can't patch it. Use it grudgingly — a default policy re-centralizes all your sanitization decisions in one function that has no idea what context it's being called from, which is most of the way back to where you started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;Trusted Types and the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/HTML_Sanitizer_API" rel="noopener noreferrer"&gt;Sanitizer API&lt;/a&gt; solve adjacent halves of the same problem and pair well: the Sanitizer decides &lt;em&gt;what HTML is safe&lt;/em&gt;, Trusted Types enforces &lt;em&gt;that something made that decision at all&lt;/em&gt;. Neither replaces a strict, nonce-based CSP for the server-rendered side of XSS.&lt;/p&gt;

&lt;p&gt;If you own an app that handles anything sensitive, the report-only header is close to free — one line of config, zero behavior change, and a list of exactly where your DOM XSS risk lives. Start there, and decide later whether to enforce.&lt;/p&gt;

&lt;p&gt;Further reading: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API" rel="noopener noreferrer"&gt;MDN's Trusted Types API reference&lt;/a&gt; and the &lt;a href="https://web.dev/articles/trusted-types" rel="noopener noreferrer"&gt;web.dev deep dive&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>webapis</category>
      <category>tooling</category>
    </item>
    <item>
      <title>The Long Animation Frames API: Find What Actually Broke Your INP</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Mon, 10 Aug 2026 08:06:37 +0000</pubDate>
      <link>https://dev.to/grimicorn/the-long-animation-frames-api-find-what-actually-broke-your-inp-bep</link>
      <guid>https://dev.to/grimicorn/the-long-animation-frames-api-find-what-actually-broke-your-inp-bep</guid>
      <description>&lt;p&gt;Your real user monitoring dashboard says the 75th percentile INP on your product page is 412ms. You open DevTools, click the same button forty times, and every interaction comes back at 60ms. Nothing reproduces. So you start guessing: maybe it's the analytics tag, maybe it's the third-party chat widget, maybe it's that one &lt;code&gt;useEffect&lt;/code&gt; that everybody is afraid to touch.&lt;/p&gt;

&lt;p&gt;The Long Tasks API was supposed to help here, and it mostly didn't. It would tell you a task ran for 210ms and attribute it to "self" or an iframe container, which is roughly as actionable as a smoke alarm that only reports "somewhere in the house." The Long Animation Frames API is the replacement, and it does the thing you actually wanted all along: it hands you a script URL, a function name, and a character offset.&lt;/p&gt;

&lt;h2&gt;
  
  
  A frame is a better unit of measurement than a task
&lt;/h2&gt;

&lt;p&gt;Slow interactions are rarely one fat task. They're usually five medium tasks, a &lt;code&gt;ResizeObserver&lt;/code&gt; callback, and a style recalculation, all landing between two paints. Long Tasks only saw the fat ones, and it stopped measuring before rendering even started.&lt;/p&gt;

&lt;p&gt;LoAF widens the window. It reports on any animation frame whose total work exceeded 50ms, covering everything from the first task in that frame through style, layout, and paint. Entries arrive with &lt;code&gt;entryType&lt;/code&gt; of &lt;code&gt;long-animation-frame&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;blockingDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blockingDuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;scriptTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renderStart&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renderStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;styleAndLayout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;styleAndLayoutStart&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paintTime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;styleAndLayoutStart&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;long-animation-frame&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four timestamps do most of the work. &lt;code&gt;startTime&lt;/code&gt; is when the frame began, &lt;code&gt;renderStart&lt;/code&gt; is when script handed off to the rendering pipeline, &lt;code&gt;styleAndLayoutStart&lt;/code&gt; is where recalc and layout begin, and &lt;code&gt;paintTime&lt;/code&gt; is where it ends. Subtracting them tells you whether you have a JavaScript problem or a layout problem, which are fixed in completely different ways.&lt;/p&gt;

&lt;p&gt;The one field to alert on is &lt;code&gt;blockingDuration&lt;/code&gt;. It sums the over-50ms portion of every long task in the frame plus the rendering time, so it approximates how long the main thread was genuinely unavailable to a user. A 300ms frame with a 20ms &lt;code&gt;blockingDuration&lt;/code&gt; is fine. A 120ms frame with a 90ms &lt;code&gt;blockingDuration&lt;/code&gt; is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Script attribution is the whole point
&lt;/h2&gt;

&lt;p&gt;Every LoAF entry carries a &lt;code&gt;scripts&lt;/code&gt; array of &lt;code&gt;PerformanceScriptTiming&lt;/code&gt; objects, and this is where the guessing stops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// https://cdn.example.com/widget.js&lt;/span&gt;
  &lt;span class="na"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceFunctionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// handleScroll&lt;/span&gt;
  &lt;span class="na"&gt;char&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceCharPosition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 18422&lt;/span&gt;
  &lt;span class="na"&gt;invoker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// window.onscroll&lt;/span&gt;
  &lt;span class="na"&gt;invokerType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invokerType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// event-listener&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;forcedLayout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forcedStyleAndLayoutDuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pauseDuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;invokerType&lt;/code&gt; tells you &lt;em&gt;how&lt;/em&gt; the script got run: &lt;code&gt;event-listener&lt;/code&gt;, &lt;code&gt;user-callback&lt;/code&gt;, &lt;code&gt;resolve-promise&lt;/code&gt;, &lt;code&gt;module-script&lt;/code&gt;, &lt;code&gt;classic-script&lt;/code&gt;. That distinction matters, because a slow &lt;code&gt;event-listener&lt;/code&gt; is your code to fix and a slow &lt;code&gt;classic-script&lt;/code&gt; during load is usually a tag manager you need to defer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;forcedStyleAndLayoutDuration&lt;/code&gt; is quietly the best field in the API. If it's a meaningful chunk of &lt;code&gt;duration&lt;/code&gt;, you have layout thrashing: something read &lt;code&gt;offsetHeight&lt;/code&gt; or &lt;code&gt;getBoundingClientRect()&lt;/code&gt; inside a write loop and forced synchronous layout. You now know which function, in which file, at which character.&lt;/p&gt;

&lt;p&gt;The caveat worth knowing before you build dashboards on this: attribution only covers same-origin main-thread scripts. Cross-origin iframes, web workers, service workers, and browser extensions can lengthen a frame without ever showing up in &lt;code&gt;scripts&lt;/code&gt;. When you see a big &lt;code&gt;duration&lt;/code&gt; with a nearly empty &lt;code&gt;scripts&lt;/code&gt; array, that absence is itself the signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tie it to the interaction, not just the clock
&lt;/h2&gt;

&lt;p&gt;A stream of slow frames is noise until you connect it to a specific bad interaction. The easiest path is &lt;code&gt;web-vitals&lt;/code&gt; with the attribution build, which already correlates LoAF entries with the INP interaction for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onINP&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;web-vitals/attribution&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;onINP&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;culprit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longAnimationFrameEntries&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="nf"&gt;beacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/rum&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;inp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interactionTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputDelay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;processing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processingDuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;presentation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;presentationDelay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;culprit&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;culprit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceURL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;culprit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceFunctionName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send the single worst script, not the whole entry. A busy page can produce dozens of LoAFs in a session and serializing all of them will cost you more than the insight is worth.&lt;/p&gt;

&lt;p&gt;LoAF shipped in Chrome 123 after an origin trial, and it's still Chromium-only. That sounds limiting until you remember that Chrome is also where your Core Web Vitals field data comes from, so the coverage gap and the metric you're optimizing line up almost exactly. Start by logging &lt;code&gt;blockingDuration&lt;/code&gt; and the top script for interactions over 200ms, then read the &lt;a href="https://developer.chrome.com/docs/web-platform/long-animation-frames" rel="noopener noreferrer"&gt;Chrome documentation&lt;/a&gt; and the &lt;a href="https://w3c.github.io/long-animation-frames/" rel="noopener noreferrer"&gt;spec&lt;/a&gt; once you know which part of your frame is actually on fire.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>webapis</category>
      <category>javascript</category>
      <category>webperf</category>
    </item>
    <item>
      <title>Invoker Commands: Wiring Buttons to Dialogs and Popovers Without JavaScript</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Thu, 02 Jul 2026 14:09:01 +0000</pubDate>
      <link>https://dev.to/grimicorn/invoker-commands-wiring-buttons-to-dialogs-and-popovers-without-javascript-5agk</link>
      <guid>https://dev.to/grimicorn/invoker-commands-wiring-buttons-to-dialogs-and-popovers-without-javascript-5agk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/invoker-commands-buttons-dialogs-popovers-without-javascript" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every time you wire up a modal, you end up writing the same three lines of JavaScript: grab the button, grab the dialog, attach a click listener that calls &lt;code&gt;showModal()&lt;/code&gt;. Do it again for the close button. Do it again for the next popover, the next dropdown, the next confirmation box. Across a real app, that adds up to a pile of glue code whose only job is connecting one element to another. The Invoker Commands API deletes most of that glue by letting a button say, right in the HTML, what it controls and what it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two attributes, no listener
&lt;/h2&gt;

&lt;p&gt;The API adds two attributes to &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;: &lt;code&gt;commandfor&lt;/code&gt; points at the &lt;code&gt;id&lt;/code&gt; of the element you want to control, and &lt;code&gt;command&lt;/code&gt; names the action. For a dialog, that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;command=&lt;/span&gt;&lt;span class="s"&gt;"show-modal"&lt;/span&gt; &lt;span class="na"&gt;commandfor=&lt;/span&gt;&lt;span class="s"&gt;"confirm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Delete&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dialog&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"confirm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Delete this item?&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;command=&lt;/span&gt;&lt;span class="s"&gt;"close"&lt;/span&gt; &lt;span class="na"&gt;commandfor=&lt;/span&gt;&lt;span class="s"&gt;"confirm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Cancel&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dialog&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no script here at all. The first button opens the dialog as a modal; the button inside closes it. The browser ships a set of built-in commands for exactly the elements that used to need boilerplate: dialogs get &lt;code&gt;show-modal&lt;/code&gt; and &lt;code&gt;close&lt;/code&gt;, and popovers get &lt;code&gt;toggle-popover&lt;/code&gt;, &lt;code&gt;show-popover&lt;/code&gt;, and &lt;code&gt;hide-popover&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A popover menu is just as terse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;command=&lt;/span&gt;&lt;span class="s"&gt;"toggle-popover"&lt;/span&gt; &lt;span class="na"&gt;commandfor=&lt;/span&gt;&lt;span class="s"&gt;"menu"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Menu&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"menu"&lt;/span&gt; &lt;span class="na"&gt;popover&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/profile"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Profile&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/logout"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Log out&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this beats a click handler
&lt;/h2&gt;

&lt;p&gt;The obvious win is less code, but the real win is behavior you would otherwise have to remember to implement. Because the browser owns the interaction, you inherit correct focus management, the &lt;code&gt;Escape&lt;/code&gt;-to-close behavior for dialogs and popovers, and the accessibility relationships between the trigger and its target for free. That is a category of bug (the modal that traps focus wrong, the popover that does not close on outside click) that simply stops happening.&lt;/p&gt;

&lt;p&gt;There is also a deliberate constraint: only &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; can be an invoker. Links and inputs cannot use &lt;code&gt;commandfor&lt;/code&gt;, because a command is an action, and buttons are the element with the right keyboard and semantic behavior for actions. And since the wiring lives in markup, it works the moment the HTML parses, before any JavaScript hydrates. For server-rendered pages and islands-style architectures, that means your dialogs and menus are interactive without waiting on a bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom commands for your own behavior
&lt;/h2&gt;

&lt;p&gt;Built-ins cover dialogs and popovers, but the API is not limited to them. You can define your own command with a double-dash prefix, following the same "dashed-ident" convention as CSS custom properties. The &lt;code&gt;--&lt;/code&gt; namespace is reserved: the browser guarantees it will never ship a built-in command starting with it, so your names can never collide with a future addition.&lt;/p&gt;

&lt;p&gt;A custom command fires a &lt;code&gt;command&lt;/code&gt; event on the &lt;em&gt;target&lt;/em&gt; element, and you listen there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;command=&lt;/span&gt;&lt;span class="s"&gt;"--rotate-left"&lt;/span&gt; &lt;span class="na"&gt;commandfor=&lt;/span&gt;&lt;span class="s"&gt;"photo"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Rotate left&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;command=&lt;/span&gt;&lt;span class="s"&gt;"--reset"&lt;/span&gt; &lt;span class="na"&gt;commandfor=&lt;/span&gt;&lt;span class="s"&gt;"photo"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Reset&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"photo"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/cat.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"A cat, upright"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;photo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;command&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--rotate-left&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// rotate and update alt text&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--reset&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// reset&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// event.source is the button that triggered the command&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha worth internalizing: the &lt;code&gt;command&lt;/code&gt; event does &lt;strong&gt;not&lt;/strong&gt; bubble. You have to listen on the target element itself, not on a shared ancestor, so event delegation patterns will not pick it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where support stands
&lt;/h2&gt;

&lt;p&gt;This is not a "someday" feature. As of late 2025 the Invoker Commands API reached Baseline across Chrome and Edge (135+), Firefox, and Safari, so you can use it in production for the browsers most projects target. If you still support older versions, it degrades cleanly: a &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; with &lt;code&gt;command&lt;/code&gt;/&lt;code&gt;commandfor&lt;/code&gt; is just a button where the attributes are ignored, so a small click-handler fallback (or a feature check like &lt;code&gt;"command" in HTMLButtonElement.prototype&lt;/code&gt;) keeps things working everywhere.&lt;/p&gt;

&lt;p&gt;The practical rule of thumb: reach for &lt;code&gt;command&lt;/code&gt; and &lt;code&gt;commandfor&lt;/code&gt; first for dialogs, popovers, and simple UI toggles, and only drop down to JavaScript when you need genuinely custom behavior, which the custom-command event gives you a clean hook for. You end up with fewer listeners, less hydration-order fragility, and accessibility handled by the platform instead of by you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/invoker-commands-buttons-dialogs-popovers-without-javascript" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webapis</category>
      <category>html</category>
      <category>a11y</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Fallow: A Codebase Truth Layer for Agent-Written Code</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Wed, 01 Jul 2026 19:29:44 +0000</pubDate>
      <link>https://dev.to/grimicorn/fallow-a-codebase-truth-layer-for-agent-written-code-3gk4</link>
      <guid>https://dev.to/grimicorn/fallow-a-codebase-truth-layer-for-agent-written-code-3gk4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/fallow-a-codebase-truth-layer-for-agent-written-code" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most of the code in my side projects lately wasn't typed by me. I write the issue, an agent writes the branch, and I review the PR. That workflow is fast, but it quietly moves the hard part downstream. When you review a lot of generated diffs, you stop worrying about whether a function works and start worrying about the stuff a diff never shows you: the export nothing imports anymore, the near-identical helper that already exists two folders over, the module that just started reaching across a boundary it had no business touching.&lt;/p&gt;

&lt;p&gt;ESLint and Prettier don't catch any of that, because they can't. A linter reads one file at a time. The problems I actually run into are relationships between files, and that's exactly the gap &lt;a href="https://github.com/fallow-rs/fallow" rel="noopener noreferrer"&gt;Fallow&lt;/a&gt; fills.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Fallow actually checks
&lt;/h2&gt;

&lt;p&gt;Fallow calls itself a "codebase truth layer," and the framing is accurate. Instead of linting files, it builds a module graph across your whole TypeScript/JavaScript project and answers questions no file-local tool can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dead code&lt;/strong&gt; — unused files, exports, dependencies, types, and circular dependencies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplication&lt;/strong&gt; — copy-pasted blocks, from exact matches to semantic clones with renamed variables&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complexity&lt;/strong&gt; — the riskiest functions and the files worth refactoring first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture drift&lt;/strong&gt; — imports that cross layer or module boundaries they shouldn't&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pitch that sold me is the honest one on the README: "Built for AI-assisted development. No AI inside." It's a Rust binary, it's deterministic, and it's fast enough that speed never becomes an excuse to skip it. On a 20,000-file Next.js project it finishes dead-code analysis in under two seconds. On anything I'm building solo, it's basically instant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx fallow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Dead code   3 unused files, 12 unused exports, 2 unused deps       18ms
 Duplication 4 clone groups (2.1% of codebase)                      31ms
 Complexity  7 functions exceed thresholds                           4ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No config for the first run. It ships 90 framework plugins, so it already knows what an entry point looks like in Next, Nuxt, SvelteKit, Astro, and the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fits with my other guardrails
&lt;/h2&gt;

&lt;p&gt;Fallow didn't replace anything in my setup. It filled the one hole the others left.&lt;/p&gt;

&lt;p&gt;The way I think about it now, each tool owns a different scale. Prettier owns formatting. ESLint owns file-local correctness. My &lt;code&gt;CLAUDE.md&lt;/code&gt; (or &lt;code&gt;AGENTS.md&lt;/code&gt;) owns intent: a handful of rules I want every generated change to respect, like rule of three before you abstract, early returns over nested conditionals, and keep functions small. Those guidelines shape the code as the agent writes it. Fallow owns the part none of the others can see, which is what the change did to the codebase as a whole.&lt;/p&gt;

&lt;p&gt;The command that ties it into review is &lt;code&gt;audit&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx fallow audit &lt;span class="nt"&gt;--format&lt;/span&gt; json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It scopes dead code, duplication, and complexity to just the changed files and returns a verdict: pass, warn, or fail, with a real exit code. That runs cleanly in CI, but it's just as useful the moment before I open a PR. If the agent left an orphaned export or quietly duplicated a utility, I see it as a fact instead of hoping I'll spot it by eye at line 300 of a diff.&lt;/p&gt;

&lt;p&gt;Because the JSON output includes a per-issue &lt;code&gt;actions&lt;/code&gt; array, the agent itself can consume it. The loop becomes: generate the change, run &lt;code&gt;fallow --format json&lt;/code&gt;, read the findings, fix the obvious ones, and hand me a cleaner PR to review. There's an MCP server for wiring it directly into Claude Code, Cursor, and friends, but honestly the CLI plus a line in the agent instructions gets you most of the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;Running it once costs nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx fallow            &lt;span class="c"&gt;# dead code + duplication + health&lt;/span&gt;
fallow dead-code      &lt;span class="c"&gt;# just cleanup candidates&lt;/span&gt;
fallow health &lt;span class="nt"&gt;--score&lt;/span&gt; &lt;span class="c"&gt;# project health score, 0-100&lt;/span&gt;
fallow fix &lt;span class="nt"&gt;--dry-run&lt;/span&gt;  &lt;span class="c"&gt;# preview automatic cleanup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you're ready to make it stick, &lt;code&gt;fallow init&lt;/code&gt; writes a tailored &lt;code&gt;.fallowrc.json&lt;/code&gt; and can scaffold a pre-commit hook. Start every rule at &lt;code&gt;warn&lt;/code&gt; so it surfaces problems without blocking you, then promote the ones you care about to &lt;code&gt;error&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unused-files"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unused-exports"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CI it's a one-liner (&lt;code&gt;uses: fallow-rs/fallow@v2&lt;/code&gt;), and the &lt;code&gt;--baseline&lt;/code&gt; flag means you only fail on &lt;em&gt;new&lt;/em&gt; issues, which makes it painless to adopt on a codebase that's already a little messy.&lt;/p&gt;

&lt;p&gt;Delegating code to agents doesn't remove the need for review. It just changes what review is about. Fallow gives me a factual read on what a change did to the whole project, so I can spend my attention on the parts that actually need judgment instead of playing spot-the-dead-export. If you're shipping code you didn't personally type, that's a trade worth making.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/fallow-a-codebase-truth-layer-for-agent-written-code" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>typescript</category>
      <category>javascript</category>
      <category>fallow</category>
    </item>
  </channel>
</rss>
