<?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: Yusuf İhsan Görgel</title>
    <description>The latest articles on DEV Community by Yusuf İhsan Görgel (@yusufihsangorgel).</description>
    <link>https://dev.to/yusufihsangorgel</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%2F3859744%2Fa9c730c8-0d59-4e68-af52-069001dbe5a7.jpeg</url>
      <title>DEV Community: Yusuf İhsan Görgel</title>
      <link>https://dev.to/yusufihsangorgel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yusufihsangorgel"/>
    <language>en</language>
    <item>
      <title>The Biggest MCP Update Ever, Explained With a Restaurant</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Wed, 29 Jul 2026 06:43:25 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/the-biggest-mcp-update-ever-explained-with-a-restaurant-4ecp</link>
      <guid>https://dev.to/yusufihsangorgel/the-biggest-mcp-update-ever-explained-with-a-restaurant-4ecp</guid>
      <description>&lt;p&gt;&lt;em&gt;MCP 2026-07-28 is out, and sessions are gone. The whole thing in plain English.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsrnxgt336i8tws5wz8hf.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsrnxgt336i8tws5wz8hf.gif" alt="Before: a sticky load balancer pins every request to one instance. After: any instance takes the next request." width="759" height="269"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Before: the balancer is sticky, so every request goes to Instance A and dies with it. After: the next free instance takes it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is a restaurant in your city with one very good waiter. He remembers everything. Your table number, your no-onions rule, the fact that your friend wants the bill split in two. You never repeat yourself.&lt;/p&gt;

&lt;p&gt;Then the waiter gets sick.&lt;/p&gt;

&lt;p&gt;The new waiter knows nothing. Not your order, not the onions, not the split bill. You start again from zero. The whole restaurant, it turns out, was running on one person's memory.&lt;/p&gt;

&lt;p&gt;Until this week, the Model Context Protocol worked exactly like that restaurant.&lt;/p&gt;
&lt;h2&gt;
  
  
  First: what is MCP?
&lt;/h2&gt;

&lt;p&gt;If you have never heard of MCP, the docs' own one-line pitch is the fastest start: &lt;strong&gt;&lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;a USB-C port for AI applications.&lt;/a&gt;&lt;/strong&gt; It is an open standard. It lets AI applications (Claude, IDEs, chatbots, agents) plug into tools and data the same way, no matter who built them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funjdrllhbeul51hljbna.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funjdrllhbeul51hljbna.gif" alt="Why MCP exists: one integration per pair, or one per side" width="800" height="333"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Without a shared protocol you build one integration per pair. With one, you build one per side.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In our restaurant: your AI app is the &lt;strong&gt;host&lt;/strong&gt;, and it sends one &lt;strong&gt;client&lt;/strong&gt; to each kitchen it wants to order from. You, at the counter, are that client. The kitchen is the &lt;strong&gt;server&lt;/strong&gt;, a program that offers tools, resources (files and data), and prompts. The dishes on the menu are the &lt;strong&gt;tools&lt;/strong&gt;. And MCP itself is the ordering system: how you ask, how the kitchen answers, what happens when something goes wrong.&lt;/p&gt;

&lt;p&gt;On July 28, the MCP team released the &lt;strong&gt;&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28" rel="noopener noreferrer"&gt;2026-07-28 revision&lt;/a&gt;&lt;/strong&gt; (their word for a version). When the release candidate went out in May they called it the largest revision of the protocol since launch. Having now read the whole diff, I would not argue: it changes the ordering system from top to bottom.&lt;/p&gt;
&lt;h2&gt;
  
  
  The waiter is gone: MCP is now stateless
&lt;/h2&gt;

&lt;p&gt;In the old protocol, a client and a server first did a handshake called &lt;code&gt;initialize&lt;/code&gt;, a "hello, who are you?" step. After that, the server kept a &lt;strong&gt;session&lt;/strong&gt; for you. On HTTP there was even a special header, &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, that locked you to one specific server instance (one running copy of the server).&lt;/p&gt;

&lt;p&gt;That session was the waiter's memory, and it had the waiter's weakness. If the instance holding it crashed, your session died with it. So if you ran three copies of the server, the load balancer in front of them had to be sticky: it had to send you back to the same copy every time, the way a host sends you back to the same waiter. Serverless platforms broke this outright. Services like AWS Lambda start and stop instances whenever a request arrives, so there is no same copy to go back to.&lt;/p&gt;

&lt;p&gt;The new version removes sessions from the protocol completely. &lt;code&gt;initialize&lt;/code&gt; is gone. Instead, &lt;strong&gt;every request carries everything the server needs&lt;/strong&gt;, inside a small &lt;code&gt;_meta&lt;/code&gt; block: which protocol version you speak and what your client can do. Just the technical facts, not your conversation; anything else the server needs arrives as normal request parameters.&lt;/p&gt;

&lt;p&gt;So the restaurant reopened as a counter-service place: you order at the counter, receipt in hand. Your whole order is printed on the receipt, and any cashier can pick it up and serve it. If one cashier goes home, the next one reads the same receipt and continues. Nobody has to remember you.&lt;/p&gt;

&lt;p&gt;Stateless does not mean your server has to forget everything. It means the protocol no longer remembers for you. If a server needs state across calls, it mints its own handle and returns it from a tool like any other output. The model passes that handle back as an ordinary argument on the next call, so the state is visible in the request instead of hidden in the transport. It is the ticket number on your receipt: the counter does not know who you are, but it knows what number 47 ordered.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmsdvzhcu0l591uv3yr56.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmsdvzhcu0l591uv3yr56.png" alt="Old: the waiter remembers. New: the receipt carries it." width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Old: one waiter's memory. New: a receipt any cashier can read.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The smallest possible before and after:&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Before&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="err"&gt;-era):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;handshake&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;first,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;then&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;session&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;protect&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;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"initialize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&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;"protocolVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-06-18"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"capabilities"&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;"clientInfo"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&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;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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;After&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026-07-28&lt;/span&gt;&lt;span class="err"&gt;):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;handshake,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;receipt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;carries&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;everything&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;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tools/list"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&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;"_meta"&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;"io.modelcontextprotocol/protocolVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-28"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"io.modelcontextprotocol/clientCapabilities"&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="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="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;The animation at the top of this article is the same story in fifteen seconds. On the left the host is sticky: every request goes to Instance A, with a session tag pinned to it, and when A dies the client dies with it. On the right the host sends each new arrival to whichever counter is free, taking turns between A, B and C. When A dies, the queue keeps moving.&lt;/p&gt;

&lt;p&gt;This is the payoff. You can now deploy an MCP server on serverless or edge platforms (small servers close to the user), or scale it behind any ordinary load balancer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The card at the door: &lt;code&gt;server/discover&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Without a handshake, how do you learn what a server offers? One new call: &lt;code&gt;server/discover&lt;/code&gt;. It returns the server's capabilities (what it can do) and the protocol versions it supports, in a single response, and every 2026-07-28 server &lt;strong&gt;must&lt;/strong&gt; implement it. Clients do not have to call it: you can send &lt;code&gt;tools/list&lt;/code&gt; straight away and handle an unsupported-version error if one comes back. &lt;code&gt;server/discover&lt;/code&gt; is there for when you want the whole picture up front, and it is how you probe an older server over stdio.&lt;/p&gt;

&lt;p&gt;There is now a card at the door listing what this branch can do. You still ask for the dishes separately, with &lt;code&gt;tools/list&lt;/code&gt;. The card even says how long it stays fresh.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c88pdovgxbnd9pgoxwv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c88pdovgxbnd9pgoxwv.png" alt="Two questions, two different calls" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Capabilities at the door, dishes on the menu. Two different calls.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The buzzer: &lt;code&gt;subscriptions/listen&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Sometimes the kitchen needs to tell &lt;em&gt;you&lt;/em&gt; something: a resource changed, the tool list was updated. The old protocol did this with a stream over HTTP GET (a stream is a connection that stays open so the server can keep sending; over HTTP these are SSE, Server-Sent Events), plus separate &lt;code&gt;resources/subscribe&lt;/code&gt; and &lt;code&gt;resources/unsubscribe&lt;/code&gt; calls. All of that is replaced by one method: &lt;code&gt;subscriptions/listen&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Think of the buzzer they hand you at the counter: it sits on your table and rings only when your order changes. You tell the server what you care about (a resource changing, or the tool or prompt list changing), and the server opens a stream tagged with your subscription ID. When the server wants to close the stream on purpose, it should now say a proper goodbye: a small &lt;code&gt;"complete"&lt;/code&gt; result carrying that same ID. That way your client knows the difference between a normal close and a broken connection.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F27rkfcndj5m2scuxxdnr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F27rkfcndj5m2scuxxdnr.png" alt="One buzzer instead of four moving parts" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One buzzer replaces the old GET stream and both subscribe calls.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Something else quietly disappeared: you can no longer resume a broken stream. The old spec let you pick up where you left off with a &lt;code&gt;Last-Event-ID&lt;/code&gt; header, which meant the server had to remember what it had already sent you. State again, hiding in the transport. Now, if a stream breaks, you send the request again with a new ID. If your buzzer breaks, nobody repairs it mid-ring; you take a new buzzer from the counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question written on your receipt: MRTR
&lt;/h2&gt;

&lt;p&gt;Nobody can come to your table anymore; the waiter who would have carried the kitchen's question is gone. So the cashier writes on your receipt: "Which sauce?" You come back to the counter with the same receipt and the answer written on it. Any cashier can complete the order from there. That is the pattern the spec settled on, and it has a name: &lt;strong&gt;Multi Round-Trip Requests (MRTR)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In the old protocol, a server could send requests &lt;em&gt;back&lt;/em&gt; to the client in the middle of an operation. For example: "please ask the user a question" or "please run this through your model". That needs a two-way, stateful channel (stateful means the server must remember you between messages), which is exactly the thing the new version threw out.&lt;/p&gt;

&lt;p&gt;The replacement: when a server needs something from you, it does not call you. It finishes the current request with a result marked &lt;code&gt;resultType: "input_required"&lt;/code&gt;, and lists what it needs in &lt;code&gt;inputRequests&lt;/code&gt;. Your client then &lt;strong&gt;retries the same request&lt;/strong&gt; with the answers attached. If the server sent a state token, the client sends that back too, unread; that is the note scribbled on your receipt. The server reads it and picks up where it stopped.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3cwwm2k9x4h11qck317.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3cwwm2k9x4h11qck317.png" alt="The kitchen writes the question on your receipt" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The kitchen writes its question on your receipt. You come back with the answer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is also why every result must now declare a &lt;code&gt;resultType&lt;/code&gt;, either &lt;code&gt;"complete"&lt;/code&gt; or &lt;code&gt;"input_required"&lt;/code&gt;. If an older server sends nothing, clients treat it as &lt;code&gt;"complete"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The menu photo has an expiry time: caching hints
&lt;/h2&gt;

&lt;p&gt;There is a photo of the menu taped up, with a note on it: valid for 10 minutes. That is what the new caching hints are. Being stateless has a cost, because clients ask "what tools do you have?" far more often, so the spec standardises the answer's shelf life.&lt;/p&gt;

&lt;p&gt;Five results (&lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;prompts/list&lt;/code&gt;, &lt;code&gt;resources/list&lt;/code&gt;, &lt;code&gt;resources/read&lt;/code&gt;, &lt;code&gt;resources/templates/list&lt;/code&gt;) plus &lt;code&gt;server/discover&lt;/code&gt; now carry two required fields: &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt;. &lt;code&gt;ttlMs&lt;/code&gt; is how long the result stays valid, in milliseconds. &lt;code&gt;cacheScope&lt;/code&gt; decides who else may reuse it: &lt;code&gt;"private"&lt;/code&gt; means only the same logged-in user (the same auth token), &lt;code&gt;"public"&lt;/code&gt; means anyone. The photo either goes on the public board next to the card at the door, or stays on your table where only you can use it. Only final &lt;code&gt;"complete"&lt;/code&gt; results carry these hints; an &lt;code&gt;"input_required"&lt;/code&gt; half-answer can never be saved for reuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complaint codes on the wall
&lt;/h2&gt;

&lt;p&gt;Every branch of the restaurant now prints the same complaint codes on the wall. Error codes were renumbered and, for the first time, given a real rule about who owns which numbers: header mismatch is &lt;code&gt;-32020&lt;/code&gt;, missing required client capability is &lt;code&gt;-32021&lt;/code&gt;, unsupported protocol version is &lt;code&gt;-32022&lt;/code&gt;. The range &lt;code&gt;-32020&lt;/code&gt; to &lt;code&gt;-32099&lt;/code&gt; now belongs to the spec itself, and servers and clients must not invent their own codes inside it. One older number moved too: resource not found is now &lt;code&gt;-32602&lt;/code&gt; instead of &lt;code&gt;-32002&lt;/code&gt;, so clients should keep accepting the old code from older servers. Your client can react to a number instead of reading an error message and guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old side door is closing
&lt;/h2&gt;

&lt;p&gt;A release this big also says goodbye to things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Roots&lt;/strong&gt;, &lt;strong&gt;Sampling&lt;/strong&gt; and &lt;strong&gt;Logging&lt;/strong&gt; are deprecated: still working today, marked for removal. Roots let a server see your folders; Sampling let it borrow your model. Logging control moved into &lt;code&gt;_meta&lt;/code&gt;, so you set a &lt;code&gt;logLevel&lt;/code&gt; per request instead of calling &lt;code&gt;logging/setLevel&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ping&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;logging/setLevel&lt;/code&gt;&lt;/strong&gt;, and the roots list-changed notification are removed.&lt;/li&gt;
&lt;li&gt;The original &lt;strong&gt;HTTP+SSE transport&lt;/strong&gt; from 2024 (a transport is the way messages travel) is now formally deprecated under the new lifecycle policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tasks&lt;/strong&gt; moved out of the core into an extension, so the core spec stays smaller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth Dynamic Client Registration&lt;/strong&gt; is deprecated and replaced by Client ID Metadata Documents. Skip this bullet if you do not touch OAuth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The old side door still opens for a while, because the spec defines careful rules for talking to older servers. But the sign on it now says: please use the main entrance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am the one telling you this
&lt;/h2&gt;

&lt;p&gt;I am not neutral about this release. For the last few weeks I have been moving the Dart team's MCP SDK (&lt;a href="https://pub.dev/packages/dart_mcp" rel="noopener noreferrer"&gt;&lt;code&gt;package:dart_mcp&lt;/code&gt;&lt;/a&gt;) to it, with five merged pull requests so far in dart-lang/ai. The work is tracked publicly in &lt;a href="https://github.com/dart-lang/ai/issues/162" rel="noopener noreferrer"&gt;dart-lang/ai#162&lt;/a&gt;. When the final spec came out, I compared it line by line with the release candidate (the near-final draft) and posted the report there. So everything above comes from the schema and the docs themselves, not from a summary of a summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you build apps that use MCP servers:&lt;/strong&gt; wait for your SDK to update. After that there is less to get wrong: no handshake, no session to lose. Version and capabilities travel with every request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you write MCP servers:&lt;/strong&gt; tape this one to the kitchen wall. &lt;code&gt;server/discover&lt;/code&gt; is mandatory now, and every result needs a &lt;code&gt;resultType&lt;/code&gt;. Your &lt;code&gt;*/list&lt;/code&gt; and &lt;code&gt;resources/read&lt;/code&gt; results need &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt; on top of that. The painful part is anywhere you used to call back into the client: that has to be redesigned around &lt;code&gt;input_required&lt;/code&gt;, and it changes your control flow, not just your types. What you get back is a server that runs anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you are just curious:&lt;/strong&gt; the practical news is freedom in where servers can run. MCP servers used to need a machine that stays up and remembers things. Now they can run as short-lived functions close to users, and scale like any normal web service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also changed, in one breath
&lt;/h2&gt;

&lt;p&gt;A few smaller items, so you know they exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The OpenTelemetry keys &lt;code&gt;traceparent&lt;/code&gt;, &lt;code&gt;tracestate&lt;/code&gt; and &lt;code&gt;baggage&lt;/code&gt; got reserved slots in &lt;code&gt;_meta&lt;/code&gt;, so one request can be traced across services.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tools/list&lt;/code&gt; should now return tools in a stable order.&lt;/li&gt;
&lt;li&gt;A tool parameter can now be mirrored into an HTTP header with the new &lt;code&gt;x-mcp-header&lt;/code&gt; annotation, under strict naming rules.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;clientInfo&lt;/code&gt; is no longer required on requests, and servers should now send &lt;code&gt;serverInfo&lt;/code&gt; in every result's &lt;code&gt;_meta&lt;/code&gt;. Both are recommended, not mandatory.&lt;/li&gt;
&lt;li&gt;JSON Schema &lt;code&gt;$ref&lt;/code&gt; resolution over the network (fetching schema pieces from other servers) is now banned by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it changes the story above. It is the boring kind of change, which in a protocol is the good kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your receipt, please
&lt;/h2&gt;

&lt;p&gt;A restaurant that runs on one waiter's memory is a lovely place, right up until the day he is not there. Counter service is duller. It also never loses your order, and it can open a hundred branches without breaking.&lt;/p&gt;

&lt;p&gt;MCP just made that trade-off, and I think it chose well. If you want to see the change happen in real code, the Dart migration in &lt;a href="https://github.com/dart-lang/ai/issues/162" rel="noopener noreferrer"&gt;dart-lang/ai#162&lt;/a&gt; is open to read, one pull request at a time.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>programming</category>
      <category>beginners</category>
    </item>
    <item>
      <title>The object that falls out of the Set it is still in — mutable hashCode in Dart</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Fri, 24 Jul 2026 08:39:13 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/the-object-that-falls-out-of-the-set-it-is-still-in-mutable-hashcode-in-dart-2665</link>
      <guid>https://dev.to/yusufihsangorgel/the-object-that-falls-out-of-the-set-it-is-still-in-mutable-hashcode-in-dart-2665</guid>
      <description>&lt;p&gt;Here is a &lt;code&gt;Set&lt;/code&gt; with one element. One assignment later, &lt;code&gt;contains&lt;/code&gt; stops finding it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;{&lt;/span&gt;&lt;span class="s"&gt;'page'&lt;/span&gt;&lt;span class="o"&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;final&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'intro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'page'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The object is in the set. &lt;code&gt;identical&lt;/code&gt; confirms it is the same object. &lt;code&gt;seen.contains(tag)&lt;/code&gt; says it isn't there. All I did in between was change a map I had a reference to.&lt;/p&gt;

&lt;p&gt;I shipped this aliasing bug three times across two of my own packages before I caught it — once with the map feeding &lt;code&gt;hashCode&lt;/code&gt;, exactly the version above — so this is not a contrived puzzle. It is what happens when a value type puts a mutable field into its &lt;code&gt;hashCode&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the set loses it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Set&lt;/code&gt; is a hash set. When you add an object, it computes &lt;code&gt;hashCode&lt;/code&gt;, picks a bucket, and drops the object in. When you later ask &lt;code&gt;contains&lt;/code&gt;, it computes &lt;code&gt;hashCode&lt;/code&gt; again, goes to that bucket, and looks. If the two hashes differ, it looks in the wrong bucket and finds nothing.&lt;/p&gt;

&lt;p&gt;That's fine as long as &lt;code&gt;hashCode&lt;/code&gt; never changes while the object is in the set. It is the one rule hashing depends on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy5hm2vkiu7c26zmof590.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy5hm2vkiu7c26zmof590.png" alt="Left: adding the tag files it in bucket 3, the bucket its hash points at. Right: after the caller mutates the metadata map the tag's hash becomes 7, but the tag still sits in bucket 3, while contains() computes the new hash and checks bucket 7, which is empty. The object never moved; its hash did." width="800" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the &lt;code&gt;Tag&lt;/code&gt; that breaks it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stored by reference&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="kd"&gt;operator&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Tag&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_mapEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;hashCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_mapHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Element-wise, one level deep: each value compares and hashes with its own&lt;/span&gt;
&lt;span class="c1"&gt;// == and hashCode. package:collection's MapEquality does the same thing.&lt;/span&gt;
&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;_mapEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;final&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keys&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;containsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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="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="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;_mapHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;final&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// xor: order-independent&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;h&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;attrs&lt;/code&gt; is &lt;code&gt;final&lt;/code&gt;, so it looks immutable. It isn't. &lt;code&gt;final&lt;/code&gt; freezes the &lt;em&gt;reference&lt;/em&gt;, not the map it points at. The constructor stored the exact map the caller passed, and the caller still has it. When they write &lt;code&gt;attrs['page'] = 2&lt;/code&gt;, they are writing into the same map &lt;code&gt;tag.attrs&lt;/code&gt; reads. &lt;code&gt;hashCode&lt;/code&gt; folds &lt;code&gt;attrs&lt;/code&gt; in, so the moment the map changes, &lt;code&gt;tag&lt;/code&gt;'s hash changes, and the bucket the set filed it under no longer matches the bucket &lt;code&gt;contains&lt;/code&gt; computes. The object is sitting in the set, in a bucket the lookup never visits.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;final&lt;/code&gt; is the tell that makes this easy to miss. It reads as a guarantee of immutability, and for an &lt;code&gt;int&lt;/code&gt; or a &lt;code&gt;String&lt;/code&gt; it is one. For a &lt;code&gt;Map&lt;/code&gt; or a &lt;code&gt;List&lt;/code&gt; it guarantees only that you won't reassign the field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to be correct
&lt;/h2&gt;

&lt;p&gt;The rule is narrow: &lt;strong&gt;anything that feeds &lt;code&gt;hashCode&lt;/code&gt; must not change while the object could be in a hashed collection.&lt;/strong&gt; There are two honest ways to satisfy it.&lt;/p&gt;

&lt;p&gt;The first is to not put mutable state in equality at all. If &lt;code&gt;Tag&lt;/code&gt; is a handle to something — it owns a resource, or it wraps a big buffer you mutate in place — then value equality is the wrong model, and identity equality (the default, no &lt;code&gt;==&lt;/code&gt; override) is correct. Two handles are the same when they are the same object, and nothing can drift.&lt;/p&gt;

&lt;p&gt;The second, when the type really is a value and callers will compare it, is to make the state actually immutable — copy it in, and hand back something no one can write to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unmodifiable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// == and hashCode unchanged&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constructor now takes the caller's map as a parameter and stores a separate, unmodifiable copy. The caller can do whatever they like to their map; it's not the one &lt;code&gt;Tag&lt;/code&gt; holds. And &lt;code&gt;tag.attrs['page'] = 3&lt;/code&gt; throws instead of silently corrupting the object. Rerun the first snippet against this version and &lt;code&gt;contains&lt;/code&gt; stays &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Map.unmodifiable&lt;/code&gt; copies the top level, which is what the hash reads, so that is where it has to be. If you nest a mutable &lt;code&gt;List&lt;/code&gt; or &lt;code&gt;Map&lt;/code&gt; as a &lt;em&gt;value&lt;/em&gt; inside &lt;code&gt;attrs&lt;/code&gt;, that inner collection is still shared and still mutable, so either document that equality compares those by identity or freeze them too. Be explicit about which; a half-frozen value type is its own trap.&lt;/p&gt;

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

&lt;p&gt;Nobody writes &lt;code&gt;attrs['page'] = 2&lt;/code&gt; right after building a &lt;code&gt;Tag&lt;/code&gt; on purpose. It happens at a distance. A store keeps the objects in a &lt;code&gt;Set&lt;/code&gt; or uses them as &lt;code&gt;Map&lt;/code&gt; keys, hands one back to you, and you update its metadata — reasonable, the field is right there — and now a &lt;code&gt;Set&lt;/code&gt; somewhere else silently can't find it. Or a builder reuses one map across several objects, filling it in between, and they all end up sharing state. The failure surfaces far from the mutation, as a lookup that misses or a deduplication that doesn't dedupe, with no error to trace back.&lt;/p&gt;

&lt;p&gt;The check is quick. For every type in your public API that overrides &lt;code&gt;==&lt;/code&gt;, ask what its &lt;code&gt;hashCode&lt;/code&gt; reads, and whether any of it can change after construction. If it can, either drop value equality or copy the mutable parts in. Add one test that puts an instance in a &lt;code&gt;Set&lt;/code&gt;, mutates the collection you handed the constructor, and asserts the instance is still found — the assertion is one line and it fails loudly the day someone reintroduces the reference.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>My CI ran on three platforms and still could not catch this</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Fri, 24 Jul 2026 01:50:11 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/my-ci-ran-on-three-platforms-and-still-could-not-catch-this-l04</link>
      <guid>https://dev.to/yusufihsangorgel/my-ci-ran-on-three-platforms-and-still-could-not-catch-this-l04</guid>
      <description>&lt;p&gt;For thirteen releases, my &lt;a href="https://github.com/Yusufihsangorgel/audio_decode" rel="noopener noreferrer"&gt;Ogg/MP3 decoder&lt;/a&gt;'s README said this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;it decodes the same bytes to the same samples on every platform&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I believed it. The decoding happens in C, the C is compiled from the same source everywhere, the tests pass on macOS, Linux and Windows. Where would a difference even come from?&lt;/p&gt;

&lt;p&gt;I was getting the package ready to freeze at 1.0.0, which is the point where a documented promise stops being a sentence and becomes a contract. So instead of reading the claim again, I tested it. It is false, and it has been false since the first release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a claim about two architectures needs two architectures
&lt;/h2&gt;

&lt;p&gt;That sounds obvious written down. It is exactly what my CI did not do.&lt;/p&gt;

&lt;p&gt;On an Apple laptop you can do the whole thing locally, because clang will cross-compile and Rosetta will run the result. The driver ships in the repo at &lt;code&gt;doc/blog/cross-arch/detdrv.c&lt;/code&gt;; from a fresh clone, run these at the repo root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;doc/blog/cross-arch/detdrv.c &lt;span class="nb"&gt;.&lt;/span&gt;
clang &lt;span class="nt"&gt;-arch&lt;/span&gt; arm64  &lt;span class="nt"&gt;-O3&lt;/span&gt; &lt;span class="nt"&gt;-Isrc&lt;/span&gt;/third_party &lt;span class="nt"&gt;-o&lt;/span&gt; det_arm64  detdrv.c src/&lt;span class="k"&gt;*&lt;/span&gt;.c
clang &lt;span class="nt"&gt;-arch&lt;/span&gt; x86_64 &lt;span class="nt"&gt;-O3&lt;/span&gt; &lt;span class="nt"&gt;-Isrc&lt;/span&gt;/third_party &lt;span class="nt"&gt;-o&lt;/span&gt; det_x86_64 detdrv.c src/&lt;span class="k"&gt;*&lt;/span&gt;.c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;detdrv.c&lt;/code&gt; is about fifty lines: read a file, call the package's own decode function, print the channel count, the sample rate, the frame count, and an FNV-1a hash of the decoded samples. Then run both binaries over the six test fixtures and compare the hashes. &lt;code&gt;src/*.c&lt;/code&gt; here is the package's two C files, and &lt;code&gt;-O3&lt;/code&gt; is what the build actually uses, so this is the production configuration rather than a special one. The x86-64 binary runs under Rosetta, but Rosetta only executes the instructions it is handed; anything the two binaries disagree on was decided when clang compiled them, not at run time.&lt;/p&gt;

&lt;p&gt;Five of the six came back different.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fixture                      arm64             x86_64            same?
sine_44100_mono_1s.mp3       26f26e4d00bd4680  db3ed41489553717  NO
sine_44100_mono_1s.ogg       741546cc3f0e3857  9b9fa075856e40b1  NO
sine_44100_stereo_1s.mp3     62655177b196b90f  db39b9b9973061b7  NO
sine_44100_stereo_1s.ogg     b712d80468229e7f  ab1522a686a648df  NO
sine_48000_mono_halfsec.mp3  03a0cfb92eff3109  03a0cfb92eff3109  yes
sine_48000_mono_halfsec.ogg  0e38543dbad39c7e  db914b5a5a724d93  NO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same source, same compiler, same optimisation level, same input bytes. Different samples. The one that matched, a short MP3, matched by luck rather than by rule: the divergence only shows up when a sample lands close enough to a rounding boundary to fall the other way, and that clip happened to have none.&lt;/p&gt;

&lt;h2&gt;
  
  
  How big, exactly
&lt;/h2&gt;

&lt;p&gt;A hash mismatch tells you nothing about severity, so the next step is to dump both PCM buffers and diff them sample by sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sine_44100_mono_1s.ogg      12 of 44100 samples differ (0.027%), max delta 1 LSB
sine_44100_mono_1s.mp3       3 of 47232 samples differ (0.006%), max delta 1 LSB
sine_44100_stereo_1s.ogg    16 of 88200 samples differ (0.018%), max delta 1 LSB
sine_44100_stereo_1s.mp3    10 of 94464 samples differ (0.011%), max delta 1 LSB
sine_48000_mono_halfsec.ogg  5 of 24000 samples differ (0.021%), max delta 1 LSB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is all five of the fixtures that diverged. Every difference is one least-significant bit, on a fraction of a percent of samples. In 16-bit audio one LSB is one part in 32768 of peak amplitude. Nobody is going to hear it. The frame count, channel count and sample rate matched on both architectures for all six fixtures.&lt;/p&gt;

&lt;p&gt;So the audio is fine. The sentence is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the bit goes missing
&lt;/h2&gt;

&lt;p&gt;Both decoders do their arithmetic in floating point, and the C standard lets a compiler contract a multiply followed by an add into a single fused multiply-add. An &lt;code&gt;fma&lt;/code&gt; computes &lt;code&gt;a * b + c&lt;/code&gt; with one rounding instead of two, so it is &lt;em&gt;more&lt;/em&gt; accurate, and it produces a different number than the unfused version.&lt;/p&gt;

&lt;p&gt;arm64 has an &lt;code&gt;fmadd&lt;/code&gt; instruction and clang uses it freely. Baseline x86-64 does not guarantee FMA, so on that target the same expression compiles to a separate multiply and add, with a rounding in between. One rounding versus two, in a decoder that then scales to 16-bit integers, and occasionally a sample lands on the other side of &lt;code&gt;.5&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is not a bug in the two decoders this package vendors — &lt;code&gt;stb_vorbis&lt;/code&gt; for Ogg and &lt;code&gt;minimp3&lt;/code&gt; for MP3 — nor in clang or my shim. It is the default behaviour of C floating point, and &lt;code&gt;-ffp-contract=off&lt;/code&gt; turns it off. I checked what the build actually passes: only an optimisation level, no floating-point flags, so contraction is on, and the divergence I had documented as impossible is exactly what the default asks for.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;stb_vorbis&lt;/code&gt; that flag is the whole story. Rebuilt with contraction off, all three Ogg fixtures decode identically on both architectures. But the two MP3 fixtures &lt;strong&gt;still&lt;/strong&gt; differ. I disassembled the contraction-off arm64 build: it contains zero fused multiply-add instructions, so whatever is left cannot be a fused-versus-unfused split.&lt;/p&gt;

&lt;p&gt;It is &lt;code&gt;minimp3&lt;/code&gt; hand-writing its final float-to-int16 conversion once per instruction set, and the two versions round differently. The SSE path converts with &lt;code&gt;_mm_cvtps_epi32&lt;/code&gt;, which rounds to nearest and breaks ties to even. The NEON path adds &lt;code&gt;0.5f&lt;/code&gt; and truncates. The float values feeding that step are bit-identical on the two architectures once contraction is off — I decoded to float on both and hashed the result to check — so the entire residual difference is those two rounding rules disagreeing, on a handful of samples, by one. Force the plain scalar path with &lt;code&gt;-DMINIMP3_NO_SIMD&lt;/code&gt;, which also adds &lt;code&gt;0.5f&lt;/code&gt; and truncates, so x86-64 now rounds the way the NEON path does, and with contraction off as well all six fixtures finally match.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth generalising
&lt;/h2&gt;

&lt;p&gt;My CI matrix is three jobs: macOS (arm64), Ubuntu (x64), Windows (x64). It looks like it covers this. It cannot.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgw5thfm4qr8t1aea64w4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgw5thfm4qr8t1aea64w4.png" alt="On the left, three CI jobs each running their own tests and passing, with nothing connecting them. On the right, the same three jobs feeding a hash of their decoded samples into one step that compares them against each other, which is where the arm64 and x64 mismatch appears" width="799" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each job builds the package and runs the test suite, and the test suite checks that decoding produces the right frame count, the right sample rate, and a waveform whose dominant frequency is the tone it should be. Every one of those assertions is a comparison &lt;strong&gt;against itself, inside one job&lt;/strong&gt;. Nothing ever compares the macOS output against the Ubuntu output, because nothing in the matrix has both.&lt;/p&gt;

&lt;p&gt;The trap is not specific to audio:&lt;/p&gt;

&lt;p&gt;No number of independent self-comparisons tests a cross-environment claim. The only test is to carry an output from one environment into the other and compare — which a matrix that never moves an output between jobs does not do.&lt;/p&gt;

&lt;p&gt;The fix in CI, if you need the guarantee, is to have jobs upload a hash of the output and have a final job assert they all match. The fix in the docs, if you do not need the guarantee, is to stop claiming it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the docs say now
&lt;/h2&gt;

&lt;p&gt;I took the second option, because bit-identical cross-architecture output is not worth turning off contraction and &lt;code&gt;minimp3&lt;/code&gt;'s SIMD paths for a decoder whose output goes to a waveform or an FFT:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Decoding is deterministic for a given build: the same bytes decode to the same samples every time, and the geometry (channel count, sample rate, frame count) is the same everywhere. Sample values are not bit-identical across CPU architectures, though. [...] Measured across this package's own fixtures, that is around 0.03% of samples differing by one least-significant bit: inaudible, but enough that a checksum of decoded PCM will not match across a mixed-architecture fleet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last clause is the only case where any of this bites. If you decode audio on a build machine and compare a hash against audio decoded on a user's machine, or you cache by content hash across a mixed fleet, you will get misses that look like corruption and are not. Everyone else can ignore the whole thing, which is why the honest version of the sentence is longer than the false one.&lt;/p&gt;

&lt;p&gt;If you maintain a native package that computes in floating point, the check costs one afternoon: build for two architectures, hash the output, compare. I would not assume the answer.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>c</category>
      <category>cicd</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The 24.8 milliseconds you pay before ffmpeg decodes a single sample</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Fri, 24 Jul 2026 01:50:09 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/the-248-milliseconds-you-pay-before-ffmpeg-decodes-a-single-sample-3983</link>
      <guid>https://dev.to/yusufihsangorgel/the-248-milliseconds-you-pay-before-ffmpeg-decodes-a-single-sample-3983</guid>
      <description>&lt;p&gt;You have an &lt;code&gt;.ogg&lt;/code&gt; file and you need the raw samples. In Dart there is no decoder in the standard library, so you do the normal thing: you shell out to ffmpeg, read its stdout, and get your PCM. It works, it is one line, and ffmpeg is better at codecs than anything you are going to write.&lt;/p&gt;

&lt;p&gt;I wanted to know what that costs, so I measured it against decoding in the same process. The interesting number turned out not to be the one I went looking for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwyd22w75sbzob5wm5u8z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwyd22w75sbzob5wm5u8z.png" alt="Decoding the same Ogg file in process and by spawning ffmpeg, at four clip lengths. Every ffmpeg bar starts with the same 24.8 ms block of process startup, so at one second of audio almost none of the elapsed time is spent decoding" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Four clips of a 440 Hz sine, 44.1 kHz stereo, Vorbis in Ogg, at 1, 5, 15 and 30 seconds. Two ways to turn each one into interleaved 16-bit PCM:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In process.&lt;/strong&gt; &lt;code&gt;decodeOgg(bytes)&lt;/code&gt; from my &lt;a href="https://pub.dev/packages/audio_decode" rel="noopener noreferrer"&gt;audio_decode&lt;/a&gt; package, which compiles &lt;a href="https://github.com/nothings/stb" rel="noopener noreferrer"&gt;stb_vorbis&lt;/a&gt; from source through a Dart build hook and calls it over FFI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subprocess.&lt;/strong&gt; &lt;code&gt;ffmpeg -i clip.ogg -f s16le -acodec pcm_s16le -&lt;/code&gt;, run with &lt;code&gt;Process.runSync&lt;/code&gt;, reading the PCM off stdout. ffmpeg 8.1.1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nine runs each, median reported, Apple M-series laptop. The bench checks that both paths produce the same amount of audio before it trusts either timing, which matters more than it sounds like and which I will come back to.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;clip&lt;/th&gt;
&lt;th&gt;in process&lt;/th&gt;
&lt;th&gt;ffmpeg&lt;/th&gt;
&lt;th&gt;ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 s&lt;/td&gt;
&lt;td&gt;0.594 ms&lt;/td&gt;
&lt;td&gt;25.221 ms&lt;/td&gt;
&lt;td&gt;42x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 s&lt;/td&gt;
&lt;td&gt;2.108 ms&lt;/td&gt;
&lt;td&gt;27.406 ms&lt;/td&gt;
&lt;td&gt;13x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 s&lt;/td&gt;
&lt;td&gt;6.294 ms&lt;/td&gt;
&lt;td&gt;32.746 ms&lt;/td&gt;
&lt;td&gt;5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 s&lt;/td&gt;
&lt;td&gt;12.923 ms&lt;/td&gt;
&lt;td&gt;40.452 ms&lt;/td&gt;
&lt;td&gt;3x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run to run these move by a few percent, and more than that on the sub-millisecond cell: a second run, from a fresh clone of the repo, gave 0.622 ms and 24.727 ms in the first row. Nothing below depends on the third decimal.&lt;/p&gt;

&lt;p&gt;A 42x ratio at one second is the sort of number that ends up in a README, and it would be a dishonest one. Look at what the ffmpeg column actually does: thirty times more audio, and the time goes up by 15 ms. The clip length is barely moving that number. Something else is most of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The floor
&lt;/h2&gt;

&lt;p&gt;The way to find out is to give ffmpeg nothing to do. I encoded a 0.05-second clip, 4 KB, and decoded it the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Decoding a 0.05 s clip through ffmpeg still takes 24.793 ms, so that
is roughly what the process itself costs, before any audio is decoded.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the answer. About &lt;strong&gt;24.8 ms of the 25.2 ms&lt;/strong&gt; at one second is not decoding. It is &lt;code&gt;fork&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, the dynamic linker resolving ffmpeg's dependencies, ffmpeg parsing its arguments, probing the input format, setting up filters, and tearing all of that down again.&lt;/p&gt;

&lt;p&gt;Subtract it and the two decoders look like what they are, which is two competent implementations of the same codec:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;clip&lt;/th&gt;
&lt;th&gt;in process&lt;/th&gt;
&lt;th&gt;ffmpeg, decoding only&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 s&lt;/td&gt;
&lt;td&gt;0.594 ms&lt;/td&gt;
&lt;td&gt;0.428 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 s&lt;/td&gt;
&lt;td&gt;2.108 ms&lt;/td&gt;
&lt;td&gt;2.613 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 s&lt;/td&gt;
&lt;td&gt;6.294 ms&lt;/td&gt;
&lt;td&gt;7.953 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 s&lt;/td&gt;
&lt;td&gt;12.923 ms&lt;/td&gt;
&lt;td&gt;15.659 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ffmpeg is faster than me at one second and I am faster at thirty. Nobody should switch decoders over that. The 24.8 ms is the whole story, and it has nothing to do with audio.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually costs you
&lt;/h2&gt;

&lt;p&gt;Because the floor is fixed per invocation, the arithmetic is per file, not per second of audio:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One 30-minute podcast:&lt;/strong&gt; ffmpeg spends ~25 ms starting up and around a second decoding. The startup is 2-3% of the job. Shell out; it is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A thousand short voice clips:&lt;/strong&gt; 1000 × 24.8 ms is &lt;strong&gt;about 25 seconds&lt;/strong&gt;, spent entirely on process startup, before counting a single sample. Batch them into one ffmpeg call and that goes away, but the moment the files arrive one at a time, or need different decode options, you are back to paying it per file. In process, it never exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Flutter app decoding a clip in response to a tap:&lt;/strong&gt; 25 ms is a frame and a half at 60 fps, on top of whatever the decode costs, and you cannot amortise it because there is one clip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the case where it stops being an optimisation and becomes a design constraint. It is also the case where shelling out is often not available at all: on iOS you cannot spawn a process, so there the subprocess column is not a slower option, it is not an option.&lt;/p&gt;

&lt;p&gt;The honest summary is narrow. If you decode occasionally, or in a batch job where a few seconds of overhead disappears, run ffmpeg and enjoy its enormous format coverage. If you decode many files, or decode on a latency budget, or ship somewhere without a shell, the per-file floor is the reason to pull the decoder in process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that made the numbers mean anything
&lt;/h2&gt;

&lt;p&gt;Before the bench times anything, it decodes the clip both ways and checks that each produced the same amount of audio. This felt like belt-and-braces when I wrote it. It was not.&lt;/p&gt;

&lt;p&gt;A first, stricter version of the check compared the raw sample counts and threw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sample count differs for 1s: 88200 vs 87944
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;128 frames short, 256 samples across the two stereo channels, on the one-second clip only. Two decoders disagreeing about how much audio is in a file is exactly the kind of thing that quietly makes a benchmark meaningless, so I went and looked instead of loosening the check.&lt;/p&gt;

&lt;p&gt;The container settles it. &lt;code&gt;ffprobe&lt;/code&gt; reports &lt;code&gt;duration_ts=44100&lt;/code&gt; for that clip, which is the frame count the Ogg stream declares. My decoder returns 44100 frames. ffmpeg's raw PCM pipe delivers 43972, stopping 128 frames early, and only at the shortest clip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1s:  declared 44100     audio_decode 44100     ffmpeg pipe 43972
5s:  declared 220500    audio_decode 220500    ffmpeg pipe 220500
15s: declared 661500    audio_decode 661500    ffmpeg pipe 661500
30s: declared 1323000   audio_decode 1323000   ffmpeg pipe 1323000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tail-trimming edge case at very short durations. 128 frames is 2.9 ms of audio, so for most uses it does not matter. But it does mean the check has to be about how much audio each side produced, counted in frames, not about the samples matching byte for byte. The samples do not match byte for byte, and that is the second thing worth knowing: &lt;code&gt;stb_vorbis&lt;/code&gt; and ffmpeg's Vorbis decoder are two different decoders, so about half the samples differ by one least-significant bit. Both are correct; floating-point Vorbis synthesis simply does not have a single right answer to the last bit. A check that demanded identical samples would fail on every clip and tell you nothing.&lt;/p&gt;

&lt;p&gt;So the check counts frames: it reports the 128-frame gap rather than hiding it, and fails only if the two decoders ever disagree by more than about a thousand frames, a codec block's worth, which would mean one of them is genuinely dropping audio. A benchmark that does not check that both sides did the same work is measuring something, and you do not know what.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;The timings in this post come from a script in the package, not from my notes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Yusufihsangorgel/audio_decode
&lt;span class="nb"&gt;cd &lt;/span&gt;audio_decode
dart run bench/vs_ffmpeg.dart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs ffmpeg on your PATH and a C toolchain for the build hook, generates its own clips, and prints the table above plus the measured floor for your machine. If your floor is very different from 24.8 ms I would genuinely like to know: process startup varies a lot by OS and by how ffmpeg was built, and the whole argument in this post is downstream of that one number.&lt;/p&gt;

&lt;p&gt;None of this is specific to audio. Shell out to a tool in a hot path and you pay for the process, not the tool, at the same price whether you asked it to do a little work or a lot.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>ffmpeg</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Pinning the point you pinch: the one line behind anchored zoom</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 20:41:57 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/pinning-the-point-you-pinch-the-one-line-behind-anchored-zoom-m4l</link>
      <guid>https://dev.to/yusufihsangorgel/pinning-the-point-you-pinch-the-one-line-behind-anchored-zoom-m4l</guid>
      <description>&lt;p&gt;Open a photo on your phone and pinch to zoom. The spot between your fingers stays between your fingers while everything around it grows. Trackpad pinch, scroll-wheel zoom on a map, double-tap to zoom into a diagram: same feel. Whatever point you aim at holds still while the view scales around it.&lt;/p&gt;

&lt;p&gt;That behavior is anchored zoom, and it comes down to one line of arithmetic. This post is about that line. The example package is photo_zoom, which I maintain, but the math is the same whether you are building an image viewer, a map, a canvas, or a diagram editor.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdexo2i61pcixm8oimaa.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdexo2i61pcixm8oimaa.gif" alt="GIF: a marked focal point staying fixed on screen while the scene scales around it, then pans" width="680" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is a conceptual illustration of the behavior. It is not a screen recording of the Flutter widget. The marked point holds its spot on screen while the grid scales, then the whole view pans.&lt;/p&gt;

&lt;h2&gt;
  
  
  A view is a scale and a translation
&lt;/h2&gt;

&lt;p&gt;A zoomable view maps content coordinates (where a pixel sits inside the image or the map) to screen coordinates (where it lands on the display). In one dimension the entire mapping is one equation:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;screen = scale * content + translation&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scale&lt;/code&gt; is how far you are zoomed in. &lt;code&gt;translation&lt;/code&gt; is how far the content is shifted. Two numbers per axis, and that is the whole transform.&lt;/p&gt;

&lt;p&gt;Now zoom in, meaning raise &lt;code&gt;scale&lt;/code&gt;, and leave &lt;code&gt;translation&lt;/code&gt; alone. Every content value gets multiplied by a bigger number, so every point moves, including the one you were looking at. The image balloons toward a corner and the thing you cared about slides away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pin one point and solve for the rest
&lt;/h2&gt;

&lt;p&gt;Pick the point you want to hold still. Say it sits at content coordinate &lt;code&gt;F&lt;/code&gt;, and you want it to stay at screen coordinate &lt;code&gt;S&lt;/code&gt;. Put those into the mapping:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;S = scale * F + translation&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You know &lt;code&gt;S&lt;/code&gt;, &lt;code&gt;F&lt;/code&gt;, and the new &lt;code&gt;scale&lt;/code&gt;. The only unknown is &lt;code&gt;translation&lt;/code&gt;, so solve for it:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;translation = S - scale * F&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That is the line. Recompute it every time &lt;code&gt;scale&lt;/code&gt; changes, and the point at &lt;code&gt;F&lt;/code&gt; lands on &lt;code&gt;S&lt;/code&gt; on every frame. The translation is forced here: once the focal point and the scale are fixed, there is exactly one translation that keeps the point pinned. You never choose it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the focal point comes from
&lt;/h2&gt;

&lt;p&gt;A pinch hands you two things: a focal point, which is the midpoint between your two fingers, and a change in scale. Feed that focal point into &lt;code&gt;translation = S - scale * F&lt;/code&gt; and the image grows out from between your fingers instead of drifting toward a corner.&lt;/p&gt;

&lt;p&gt;The same formula covers the other zoom gestures you already use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Double-tap to zoom: the focal point is where you tapped.&lt;/li&gt;
&lt;li&gt;Scroll-wheel or trackpad zoom on desktop: the focal point is the cursor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different input, same math. Whatever point you name as the focal point is the point that stays put.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two axes, no new ideas
&lt;/h2&gt;

&lt;p&gt;Screens are 2D, and the rule runs on each axis on its own. Same formula, twice:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;translationX = focalScreenX - scale * focalContentX&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;translationY = focalScreenY - scale * focalContentY&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;There is no cross-talk between X and Y. The point you pinch is pinned horizontally and vertically by the same subtraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line, as code
&lt;/h2&gt;

&lt;p&gt;Here it is as a pure-Dart function. It takes the focal point in both coordinate spaces plus the scale, and returns the translation that pins it, as a record with &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Given where the focal point is on screen and in the content, and the&lt;/span&gt;
&lt;span class="c1"&gt;// scale, return the translation that keeps the focal point pinned.&lt;/span&gt;
&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="n"&gt;anchoredTranslation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;focalScreenX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;focalScreenY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;focalContentX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;focalContentY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;x:&lt;/span&gt; &lt;span class="n"&gt;focalScreenX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;focalContentX&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;y:&lt;/span&gt; &lt;span class="n"&gt;focalScreenY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;focalContentY&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&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;Read the body and it is the 2D formula from the last section. &lt;code&gt;focalScreenX - focalContentX * scale&lt;/code&gt; is &lt;code&gt;S - scale * F&lt;/code&gt; on the X axis, and the same on Y.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it holds
&lt;/h2&gt;

&lt;p&gt;Here is the check I ran. Drop this into the same file as the function above and run it. It fixes a focal point, zooms from scale 1.0 to 3.0, and pushes the pinned point back through &lt;code&gt;screen = scale * content + translation&lt;/code&gt; to confirm it lands in the same place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&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="n"&gt;focalScreenX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;200.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;focalScreenY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;150.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;focalContentX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;300.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;focalContentY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;400.0&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;final&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anchoredTranslation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;focalScreenX:&lt;/span&gt; &lt;span class="n"&gt;focalScreenX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;focalScreenY:&lt;/span&gt; &lt;span class="n"&gt;focalScreenY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;focalContentX:&lt;/span&gt; &lt;span class="n"&gt;focalContentX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;focalContentY:&lt;/span&gt; &lt;span class="n"&gt;focalContentY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;scale:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;backX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;focalContentX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;backY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;focalContentY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'scale &lt;/span&gt;&lt;span class="si"&gt;$scale&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; translation (&lt;/span&gt;&lt;span class="si"&gt;${t.x}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;${t.y}&lt;/span&gt;&lt;span class="s"&gt;); '&lt;/span&gt;
        &lt;span class="s"&gt;'focal maps back to (&lt;/span&gt;&lt;span class="si"&gt;$backX&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;$backY&lt;/span&gt;&lt;span class="s"&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;Run it and you get two lines. At scale 1.0 the translation is &lt;code&gt;(-100.0, -250.0)&lt;/code&gt;. At scale 3.0 it is &lt;code&gt;(-700.0, -1050.0)&lt;/code&gt;. The translation moves a long way between the two, because at 3x the content has to shift much further to hold that one point in place. But the pinned point maps back to &lt;code&gt;(200.0, 150.0)&lt;/code&gt; at both scales. That equality, the same screen position at every scale, is the property the formula guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lives in Flutter
&lt;/h2&gt;

&lt;p&gt;In Flutter the transform is a &lt;code&gt;Matrix4&lt;/code&gt;. A &lt;code&gt;Transform&lt;/code&gt; widget applies one, and &lt;code&gt;InteractiveViewer&lt;/code&gt; maintains one for you as you pan and zoom. The &lt;code&gt;scale&lt;/code&gt; and &lt;code&gt;translation&lt;/code&gt; from above are entries in that matrix. When you pinch inside an &lt;code&gt;InteractiveViewer&lt;/code&gt;, it is running this same focal-point arithmetic to pick the matrix for the next frame.&lt;/p&gt;

&lt;p&gt;photo_zoom, the package I maintain, is a drop-in replacement for photo_view that wires this up for image viewing. It reads the pinch, double-tap, and drag gestures, builds the anchored transform internally, and scales the image around the gesture's focal point. A caller uses &lt;code&gt;PhotoView&lt;/code&gt; with an &lt;code&gt;imageProvider&lt;/code&gt; and, if they want, &lt;code&gt;minScale&lt;/code&gt;, &lt;code&gt;maxScale&lt;/code&gt;, and &lt;code&gt;initialScale&lt;/code&gt; (typed as &lt;code&gt;PhotoViewScale&lt;/code&gt;). The anchored-zoom math stays inside; the caller gets zoom that holds where they pinched.&lt;/p&gt;

&lt;p&gt;To be plain about what that buys you: anchored zoom is standard behavior. photo_view does it, &lt;code&gt;InteractiveViewer&lt;/code&gt; does it, your phone's photo app does it. None of it is special to photo_zoom. The part worth carrying to other projects is the one line, &lt;code&gt;translation = S - scale * F&lt;/code&gt;. Once you have seen it you can add anchored zoom to a custom canvas, a diagram editor, or a map yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A wrinkle: when a drag should pan and when it should dismiss
&lt;/h2&gt;

&lt;p&gt;Once you allow zooming, a drag has two possible jobs. It can pan the zoomed image, or it can dismiss the viewer and close the full-screen view. You have to decide which one a given drag means.&lt;/p&gt;

&lt;p&gt;The rule that feels right keys off whether there is anything to pan into. While the image is larger than the viewport, there is off-screen content to pan into, so a drag pans. When the image is at or below the fit scale, there is nothing to pan, so a drag can dismiss.&lt;/p&gt;

&lt;p&gt;photo_zoom 0.2.1 fixed a case here: dismiss had gone dead once the image was zoomed out below its initial scale. At that point there is nothing left to pan, so a drag should have been free to dismiss, and it was not. This is the kind of edge that shows up only once anchored zoom already works and you start spending time in the zoomed-out state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep this one line
&lt;/h2&gt;

&lt;p&gt;If you keep one thing from this, keep &lt;code&gt;translation = S - scale * F&lt;/code&gt;. Recompute it whenever the scale changes, using the focal point the gesture hands you, and the point under your finger stays under your finger. The matrix, the widget, and the package are all wrappers around that subtraction.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>graphics</category>
      <category>math</category>
    </item>
    <item>
      <title>A public const constructor is the back door around your factory's validation</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 20:15:24 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/a-public-const-constructor-is-the-back-door-around-your-factorys-validation-2hh8</link>
      <guid>https://dev.to/yusufihsangorgel/a-public-const-constructor-is-the-back-door-around-your-factorys-validation-2hh8</guid>
      <description>&lt;p&gt;You put your validation in a factory. &lt;code&gt;Schema.string(pattern: ...)&lt;/code&gt; checks that the pattern is a real regex before it hands you an object. &lt;code&gt;Schema.enumeration(values)&lt;/code&gt; refuses an empty list and copies the one you pass in. The factory is the front door, and it has a bouncer.&lt;/p&gt;

&lt;p&gt;Then someone calls the constructor instead.&lt;/p&gt;

&lt;p&gt;If the concrete class has a public constructor sitting next to that factory, it is a second door into the same object, and that door has no bouncer. Everything the factory checked, the constructor skips. This is a general Dart trap, and I hit it in my own package, &lt;code&gt;instructor_dart&lt;/code&gt;. The interesting part is why the obvious fix does not work: the constructor is &lt;code&gt;const&lt;/code&gt;, and &lt;code&gt;const&lt;/code&gt; is exactly what stops it from defending itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;instructor_dart&lt;/code&gt; takes a plain-Dart schema and a prompt and returns a validated, correctly-typed Dart object from an LLM (OpenAI, Anthropic, Gemini). You describe the shape you want with a small schema API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:instructor_dart/instructor_dart.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;pattern:&lt;/span&gt; &lt;span class="sx"&gt;r'^[^@]+@[^@]+$'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enumeration&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;'low'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'medium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'high'&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;Schema&lt;/code&gt; is a sealed base type with static factory methods: &lt;code&gt;Schema.string()&lt;/code&gt;, &lt;code&gt;Schema.integer()&lt;/code&gt;, &lt;code&gt;Schema.number()&lt;/code&gt;, &lt;code&gt;Schema.boolean()&lt;/code&gt;, &lt;code&gt;Schema.enumeration()&lt;/code&gt;, &lt;code&gt;Schema.list()&lt;/code&gt;, and &lt;code&gt;Schema.object()&lt;/code&gt;. Each one returns a concrete type: &lt;code&gt;StringSchema&lt;/code&gt;, &lt;code&gt;IntegerSchema&lt;/code&gt;, &lt;code&gt;EnumSchema&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;Those concrete types are where the problem lived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two doors, one object
&lt;/h2&gt;

&lt;p&gt;Before 0.4.0, the concrete classes had public const constructors. So you could build a &lt;code&gt;StringSchema&lt;/code&gt; two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Schema.string(pattern: '(')&lt;/code&gt; goes through the factory, which validates.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;StringSchema(pattern: '(')&lt;/code&gt; goes straight to the constructor, which does not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both gave you a &lt;code&gt;StringSchema&lt;/code&gt;. Only one of them checked anything. Two of the factory invariants were real, and both were skippable through the raw constructor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The regex is validated eagerly.&lt;/strong&gt; &lt;code&gt;Schema.string(pattern: ...)&lt;/code&gt; runs &lt;code&gt;RegExp(pattern)&lt;/code&gt; at construction time. If you pass a broken pattern, you get a &lt;code&gt;FormatException&lt;/code&gt; right there, at the line where you wrote the mistake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Schema.string runs RegExp(pattern) and throws FormatException on a bad pattern:&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;pattern:&lt;/span&gt; &lt;span class="s"&gt;'('&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call the raw &lt;code&gt;StringSchema(pattern: '(')&lt;/code&gt; constructor and that check never runs. The broken pattern gets stored and sits there until something tries to match against it, which is usually far away from the typo, in a stack trace that points at the matcher instead of at you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The enum set cannot be empty, and it gets copied.&lt;/strong&gt; &lt;code&gt;Schema.enumeration(values)&lt;/code&gt; throws &lt;code&gt;ArgumentError&lt;/code&gt; if &lt;code&gt;values&lt;/code&gt; is empty, and it copies the list with &lt;code&gt;List.unmodifiable&lt;/code&gt;. So the allowed set is never empty, and nobody can mutate it after the fact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enumeration&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;'low'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'medium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw &lt;code&gt;EnumSchema(const [])&lt;/code&gt; constructor skipped both. You could build an enum that matches nothing, or pass in a list you keep a reference to and change later, out from under the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Just make the constructor validate itself"
&lt;/h2&gt;

&lt;p&gt;This is the obvious idea, and in Dart it does not work, because these constructors are const.&lt;/p&gt;

&lt;p&gt;A const constructor is restricted. It can assign to fields and it can run &lt;code&gt;assert&lt;/code&gt; statements in its initializer list. That is the whole menu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Port&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// A const constructor may assert. That is as far as it goes.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;65535&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;assert&lt;/code&gt; has two problems for real validation. It only runs in debug mode, so in a release build it is gone. And it takes a boolean condition, so it cannot express "parse this string as a regular expression and throw a &lt;code&gt;FormatException&lt;/code&gt; if it fails." There is no way to call &lt;code&gt;RegExp(pattern)&lt;/code&gt; from inside a const constructor, and no way to throw an &lt;code&gt;ArgumentError&lt;/code&gt; conditionally the way an ordinary method can.&lt;/p&gt;

&lt;p&gt;So the real checks have to live somewhere that can run real code: a factory, or a static method. That is the actual reason these packages reach for factories in the first place. The factory is the only place a check can live, and a public const constructor sits right beside it as a way around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix in 0.4.0
&lt;/h2&gt;

&lt;p&gt;Make the concrete constructor library-private. Each concrete class now has a private named constructor, and the factory routes through it. In shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;StringSchema&lt;/code&gt; has &lt;code&gt;const StringSchema._(...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Schema.string(...)&lt;/code&gt; does its regex check, then calls &lt;code&gt;StringSchema._(...)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From a caller's side, the change is small and total. The factory still works exactly as before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Still the way to do it:&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;pattern:&lt;/span&gt; &lt;span class="sx"&gt;r'^[^@]+@[^@]+$'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing &lt;code&gt;StringSchema(pattern: r'^[^@]+@[^@]+$')&lt;/code&gt; is now an analyzer error: the class has no accessible unnamed constructor. There is no second door to walk through.&lt;/p&gt;

&lt;p&gt;The concrete classes stay exported as types, and that part matters. You still get &lt;code&gt;StringSchema&lt;/code&gt; and &lt;code&gt;EnumSchema&lt;/code&gt; as return types, you can still &lt;code&gt;switch&lt;/code&gt; over the sealed &lt;code&gt;Schema&lt;/code&gt; hierarchy and match on them, and you can still read their fields. Closing the constructor closes construction only; the type is still yours to use.&lt;/p&gt;

&lt;p&gt;One more thing fell out of this. The factories do not take an &lt;code&gt;isOptional&lt;/code&gt; argument, so once the raw constructors were closed, the only way left to mark a field optional is the &lt;code&gt;.optional()&lt;/code&gt; method that was the intended one. Removing the public constructor removed the shortcut around that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest scope: this was mostly preventive
&lt;/h2&gt;

&lt;p&gt;I want to be exact about what this changed, because it did not fix five broken validators.&lt;/p&gt;

&lt;p&gt;Of the seven factories, only two enforced an invariant that the raw constructor skipped: &lt;code&gt;string&lt;/code&gt; with its regex check, and &lt;code&gt;enumeration&lt;/code&gt; with its non-empty-and-copy rule. Those two were real holes, and closing the constructor closed them.&lt;/p&gt;

&lt;p&gt;The other five (&lt;code&gt;integer&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;object&lt;/code&gt;) forwarded their arguments and validated nothing. For those, there was no bug to fix. Closing their constructors is preventive: it means that the day someone adds a check to &lt;code&gt;Schema.integer()&lt;/code&gt;, there is no public &lt;code&gt;IntegerSchema(...)&lt;/code&gt; sitting next to it to route around the check. The single construction path is set up before it carries anything worth protecting.&lt;/p&gt;

&lt;p&gt;So the count is small: two real checks locked, and five constructors closed before they ever guard anything, with all seven now built through one path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this had to happen before 1.0.0
&lt;/h2&gt;

&lt;p&gt;Removing a public constructor is a breaking change. Any code that wrote &lt;code&gt;StringSchema(...)&lt;/code&gt; stops compiling.&lt;/p&gt;

&lt;p&gt;Once a package puts out a stable 1.0.0, you cannot take a public constructor away without going to 2.0.0. &lt;code&gt;instructor_dart&lt;/code&gt; is young, so the cost of the break right now rounds to zero. After 1.0.0 the same cleanup costs a major version and a migration for everyone who depends on you. This was the cheap moment, so I took it.&lt;/p&gt;

&lt;h2&gt;
  
  
  If a factory validates, close the constructor
&lt;/h2&gt;

&lt;p&gt;The specific bug was mine, but the shape is everywhere: any value type with a validating factory and a public constructor has a back door. If you write these in Dart, the mechanics are worth keeping in your head. Here is the &lt;code&gt;Port&lt;/code&gt; from earlier with the door closed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Port&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// The only real constructor, and it is private.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Port&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// The public door, and it can run whatever checks it wants.&lt;/span&gt;
  &lt;span class="kd"&gt;factory&lt;/span&gt; &lt;span class="n"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;65535&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;ArgumentError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'value'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'must be 1..65535'&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="n"&gt;Port&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give the class a private named constructor (&lt;code&gt;ClassName._&lt;/code&gt;), route the factory through it, and if the type is part of a sealed hierarchy, keep the class exported so it still works as a type in returns and &lt;code&gt;switch&lt;/code&gt; arms. The const constructor cannot carry the validation itself, because const constructors can only assign and assert. The factory is where the checks live, and a public constructor next to it is a way around them.&lt;/p&gt;

&lt;p&gt;Go look at your own value types. If the validation is in the factory and the constructor is public, you have two doors, and callers will find the one without the bouncer.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>2.75 seconds or 2 microseconds: the same regex, two engines</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 19:59:12 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/275-seconds-or-2-microseconds-the-same-regex-two-engines-k2o</link>
      <guid>https://dev.to/yusufihsangorgel/275-seconds-or-2-microseconds-the-same-regex-two-engines-k2o</guid>
      <description>&lt;p&gt;Here is a regular expression: &lt;code&gt;(a+)+$&lt;/code&gt;. Here is an input: a string of 28 &lt;code&gt;a&lt;/code&gt; characters followed by one &lt;code&gt;!&lt;/code&gt;, which the pattern cannot match.&lt;/p&gt;

&lt;p&gt;Ask Dart's built-in &lt;code&gt;RegExp&lt;/code&gt; whether the pattern matches that input, and on my laptop it takes 2.75 seconds to return &lt;code&gt;false&lt;/code&gt;. Ask Google's RE2 engine the same question about the same bytes, and it returns &lt;code&gt;false&lt;/code&gt; in about 2 microseconds. Same pattern, same string, same answer. The only thing that changed is which engine did the matching, and it made the answer arrive roughly a million times sooner.&lt;/p&gt;

&lt;p&gt;Both numbers are real. I ran the benchmark on Apple Silicon with Dart 3.11, and both engines saw byte-for-byte identical input. The gap is not something you can close by writing a cleverer pattern. Here is where it comes from, and when it is worth paying RE2's cost to avoid it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finfs040giya6mtiofiel.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finfs040giya6mtiofiel.png" alt="Match time for the pattern (a+)+$ on a log scale: Dart's RegExp climbs exponentially to 2.75 seconds at 28 characters while re2 stays flat near 2 microseconds" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;The pattern is &lt;code&gt;(a+)+$&lt;/code&gt;. The input is &lt;code&gt;n&lt;/code&gt; copies of the letter &lt;code&gt;a&lt;/code&gt; followed by one &lt;code&gt;!&lt;/code&gt;, so the match always fails (the string does not end the way the pattern demands). I fed the identical pattern and input to both engines and timed the match. Times are in microseconds; I write &lt;code&gt;us&lt;/code&gt; for microseconds and &lt;code&gt;ms&lt;/code&gt; for milliseconds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;Dart RegExp (us)&lt;/th&gt;
&lt;th&gt;re2 (us)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;4,712&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;10,490&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;15,671&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;49,416&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;169,381&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;679,067&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;2,746,957 (2.75 s)&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;re2 sits flat at 2 to 3 us the whole way down the column. Dart's &lt;code&gt;RegExp&lt;/code&gt; roughly quadruples every time &lt;code&gt;n&lt;/code&gt; goes up by two. By &lt;code&gt;n=28&lt;/code&gt; it is spending 2.75 seconds to answer a yes/no question about a 29-character string.&lt;/p&gt;

&lt;p&gt;This is the shape of a ReDoS (regular expression denial of service) bug. A pattern that looks ordinary meets an input that is a little bit adversarial, and one match call ties up the isolate for seconds. This is not hypothetical: in &lt;a href="https://github.com/dart-lang/sdk/issues/61284" rel="noopener noreferrer"&gt;dart-lang/sdk#61284&lt;/a&gt; an ordinary URL pattern froze a running app on iOS. Nobody wrote that pattern to be malicious. It just had the wrong shape and met the wrong input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one explodes and the other does not
&lt;/h2&gt;

&lt;p&gt;Both engines answer the same question. They go about it in completely different ways, and that difference is the entire 2.75-seconds-versus-2-microseconds gap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flmgmjso9jvps3v9i4o3x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flmgmjso9jvps3v9i4o3x.png" alt="Side by side: a backtracking engine enumerating every way to split the input and backtracking through 134 million of them, versus RE2 making one left-to-right pass over a state machine in n steps" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A backtracking engine treats matching as a search
&lt;/h3&gt;

&lt;p&gt;For &lt;code&gt;(a+)+&lt;/code&gt; to match a run of a's, the engine has to decide how to divide that run between the inner &lt;code&gt;a+&lt;/code&gt; and the outer repetition. Five a's could be one group of five, or four then one, or two then three, and so on. As long as more a's keep coming, the greedy path just extends and the division never has to be reconsidered; the engine takes the first split that works and moves on.&lt;/p&gt;

&lt;p&gt;The trouble starts at the character after the run. In our input that character is &lt;code&gt;!&lt;/code&gt;, and the pattern still needs &lt;code&gt;$&lt;/code&gt; (end of string) there. The match fails at that point. A backtracking engine does not give up there. It backs up and tries the next way of dividing the a's, then the next, then the next, on the theory that some other division might let the rest of the pattern match.&lt;/p&gt;

&lt;p&gt;The number of ways to split a run of &lt;code&gt;n&lt;/code&gt; a's into ordered groups is 2 to the power &lt;code&gt;n-1&lt;/code&gt;. At &lt;code&gt;n=28&lt;/code&gt; that is 2^27, which is 134,217,728 divisions the engine works through before it can be sure there is no match. Every extra &lt;code&gt;a&lt;/code&gt; doubles that count. That is where the "quadruple every time n goes up by two" in the table comes from: one more character, twice the work; two more, four times.&lt;/p&gt;

&lt;h3&gt;
  
  
  RE2 does not search
&lt;/h3&gt;

&lt;p&gt;RE2 compiles the pattern once into a finite-state machine. Then it reads the input one character at a time, and instead of tracking a single guess it tracks the entire set of states the machine could be in at that point. Each character advances that whole set by one step. The read pointer only ever moves forward; it never backs up to retry a division, because RE2 is not trying divisions at all. It is asking, after these characters, is any accepting state reachable.&lt;/p&gt;

&lt;p&gt;The linear-time guarantee is exactly this: &lt;code&gt;n&lt;/code&gt; characters take exactly &lt;code&gt;n&lt;/code&gt; steps, and no input, however it is crafted, can push an RE2 match into exponential time. There are no divisions to enumerate, so there is nothing to enumerate exponentially many of.&lt;/p&gt;

&lt;p&gt;The engine behind the &lt;code&gt;re2&lt;/code&gt; package is an FFI binding to &lt;a href="https://github.com/google/re2" rel="noopener noreferrer"&gt;Google's RE2&lt;/a&gt;, the same C++ engine, exposed to Dart.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Dart's built-in RegExp is the backtracking kind
&lt;/h3&gt;

&lt;p&gt;Dart's &lt;code&gt;RegExp&lt;/code&gt; is Irregexp, the engine that came from V8. Irregexp is a backtracking engine, which is why it is fast and full-featured on ordinary patterns and can handle features like backreferences. The same design is what lets a nested quantifier like &lt;code&gt;(a+)+&lt;/code&gt; blow up. Every backtracking engine behaves this way when a nested quantifier fails to match. The exponential search is the algorithm working as designed. There is no patch for it; a backtracking engine does this by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The million-character match
&lt;/h2&gt;

&lt;p&gt;Because RE2 takes one step per character, you can throw genuinely large input at it and watch the time stay reasonable. Each of these is &lt;code&gt;n&lt;/code&gt; a's followed by one non-matching character, the same failing shape as before, just longer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;input length (n)&lt;/th&gt;
&lt;th&gt;re2 match time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;50 us&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;138 us&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;111 us&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;1,894 us (1.9 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those numbers do not climb smoothly. At the microsecond scale, fixed overhead (crossing into native code, marshalling the string) and ordinary measurement noise dominate, so &lt;code&gt;n=100,000&lt;/code&gt; can clock in under &lt;code&gt;n=10,000&lt;/code&gt;. The trend is the point: ten times the input is nowhere near ten times the time, and a million characters match in 1.9 ms.&lt;/p&gt;

&lt;p&gt;Put that same million-character hostile input in front of a backtracking engine on &lt;code&gt;(a+)+$&lt;/code&gt; and it would have 2^999999 divisions to work through, which is longer than the age of the universe. RE2 finishes it in under two milliseconds because it never asks that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it in Dart
&lt;/h2&gt;

&lt;p&gt;The API is small. You construct a &lt;code&gt;Re2&lt;/code&gt; from a pattern and call &lt;code&gt;hasMatch&lt;/code&gt;, &lt;code&gt;firstMatch&lt;/code&gt;, or &lt;code&gt;allMatches&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:re2/re2.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// The classic catastrophic-backtracking pattern.&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Re2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sx"&gt;r'(a+)+$'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;hostile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'a'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;'!'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostile&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// returns false in microseconds; the built-in&lt;/span&gt;
                               &lt;span class="c1"&gt;// RegExp would take seconds on the same input&lt;/span&gt;
  &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&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;I used 30 a's here; since each &lt;code&gt;a&lt;/code&gt; doubles the backtracking engine's work, 30 is well past the point where the built-in &lt;code&gt;RegExp&lt;/code&gt; stalls for seconds, while RE2 does not notice the difference.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Re2&lt;/code&gt; implements Dart's &lt;code&gt;Pattern&lt;/code&gt;, so an instance drops into &lt;code&gt;String.split&lt;/code&gt;, &lt;code&gt;String.replaceAll&lt;/code&gt;, and the other methods that take a &lt;code&gt;Pattern&lt;/code&gt;. Call &lt;code&gt;dispose&lt;/code&gt; when you are done with it; it holds a native resource behind the FFI boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;RE2 is not a general speed boost for everyday regexes. On ordinary patterns and ordinary input, Dart's built-in &lt;code&gt;RegExp&lt;/code&gt; is usually the faster of the two, because every RE2 call has to cross the FFI boundary and marshal the string, and that costs something (roughly 2x in my measurements). What RE2 buys you is a ceiling on match time that holds no matter what the input is. That is a security property, and it only matters when the pattern or the input is outside your control. Most of your regexes run against strings you control, and the built-in &lt;code&gt;RegExp&lt;/code&gt; is the right tool for those.&lt;/p&gt;

&lt;p&gt;So the rule I use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reach for &lt;code&gt;re2&lt;/code&gt; when the pattern or the input is attacker-reachable: user-supplied search terms, validating uploaded content, a WAF-style ruleset, anything where someone else picks the string you match against.&lt;/li&gt;
&lt;li&gt;Keep the built-in &lt;code&gt;RegExp&lt;/code&gt; everywhere else, which is most places.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One constraint is worth knowing before you commit. RE2 does not support backreferences (&lt;code&gt;\1&lt;/code&gt;) or lookaround (&lt;code&gt;(?=...)&lt;/code&gt;, &lt;code&gt;(?&amp;lt;=...)&lt;/code&gt;) at all. Those are the features that force a backtracking implementation and open the door to the exponential blow-up, so RE2 leaves them out by design. A pattern using them throws a &lt;code&gt;FormatException&lt;/code&gt; the moment you construct the &lt;code&gt;Re2&lt;/code&gt;, long before it could bite you in production.&lt;/p&gt;

&lt;p&gt;The 2.75-seconds-versus-2-microseconds gap comes from RE2 refusing to enumerate divisions at all, which is a game a backtracking engine has no way to refuse. Once a regex runs against input someone else chose, the exponential case turns from a curiosity into an availability bug, and a linear-time engine earns its FFI cost. The package is &lt;a href="https://pub.dev/packages/re2" rel="noopener noreferrer"&gt;re2&lt;/a&gt; on pub.dev; the engine underneath is the one Google wrote for exactly this problem.&lt;/p&gt;

&lt;p&gt;Disclosure: &lt;code&gt;re2&lt;/code&gt; is my package; the RE2 engine it binds is Google's.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>regex</category>
      <category>security</category>
    </item>
    <item>
      <title>A finite loop in skeleton_shimmer that stopped resuming when the loop count was raised</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 18:10:24 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/a-finite-loop-in-skeletonshimmer-that-stopped-resuming-when-the-loop-count-was-raised-44ib</link>
      <guid>https://dev.to/yusufihsangorgel/a-finite-loop-in-skeletonshimmer-that-stopped-resuming-when-the-loop-count-was-raised-44ib</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdqurtx0e8kduwrrsfyff.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdqurtx0e8kduwrrsfyff.gif" alt="An animated illustration of two skeleton cards, the left frozen after a finite loop finishes and the right still sweeping" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The clip above is a conceptual illustration of the behavior, with a card frozen after a finite loop on the left and a card still sweeping on the right. It is not a screen recording of the running widget.&lt;/p&gt;

&lt;p&gt;skeleton_shimmer draws a shimmer loading effect, a light gradient that sweeps across gray skeleton placeholders while data loads. It is API-compatible with the shimmer package. Its &lt;code&gt;Shimmer&lt;/code&gt; widget takes a &lt;code&gt;loop&lt;/code&gt; parameter: &lt;code&gt;loop: 0&lt;/code&gt; sweeps forever, and &lt;code&gt;loop: N&lt;/code&gt; stops after N sweeps.&lt;/p&gt;

&lt;p&gt;Version 0.2.1 had a bug in the &lt;code&gt;loop: N&lt;/code&gt; path. Once a finite loop finished, raising &lt;code&gt;loop&lt;/code&gt; at runtime did not start the sweep again. The widget stayed frozen on a static gray field. Version 0.2.2 fixes it with a one-line change, and the reason that line works comes down to how &lt;code&gt;AnimationController&lt;/code&gt; behaves when it is parked at one of its bounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Give the widget &lt;code&gt;loop: 2&lt;/code&gt; and let both sweeps play. The shimmer settles on gray, which is what &lt;code&gt;loop: 2&lt;/code&gt; asks for. Now raise the count to &lt;code&gt;loop: 4&lt;/code&gt; while the widget is on screen. Before 0.2.2, nothing happened. The placeholder stayed gray and static, and the two extra sweeps never ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;The sweep is driven by an &lt;code&gt;AnimationController&lt;/code&gt; that animates from its lower bound to its upper bound, 0.0 to 1.0. One pass from 0.0 to 1.0 is one sweep.&lt;/p&gt;

&lt;p&gt;A status listener, &lt;code&gt;_onStatus&lt;/code&gt;, counts completed passes. Each time the controller reaches the end it increments a completed-loops counter, &lt;code&gt;_completedLoops&lt;/code&gt;. While that counter is below the target it calls &lt;code&gt;forward(from: 0)&lt;/code&gt; to run the next sweep. Once the counter reaches the target it stops calling &lt;code&gt;forward(from: 0)&lt;/code&gt;, and the controller is left parked at the upper bound: &lt;code&gt;value&lt;/code&gt; is 1.0, its status is completed, and it is not animating. For &lt;code&gt;loop: N&lt;/code&gt; that is correct, since the animation is meant to stop after N sweeps.&lt;/p&gt;

&lt;p&gt;Raising &lt;code&gt;loop&lt;/code&gt; at runtime goes through &lt;code&gt;didUpdateWidget&lt;/code&gt;. That resets the completed-loops counter back to 0 and calls a resync method, &lt;code&gt;_syncAnimation&lt;/code&gt;, to line the animation up with the new configuration.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;_syncAnimation&lt;/code&gt; saw that the controller was not animating and tried to resume it with a bare &lt;code&gt;forward()&lt;/code&gt;. That is where it went wrong. &lt;code&gt;forward()&lt;/code&gt; animates toward the upper bound, and the controller was already sitting at the upper bound with &lt;code&gt;value&lt;/code&gt; at 1.0. The remaining distance was zero, so the call was a zero-duration no-op: it scheduled no ticker and did not fire the status listener again. The sweep never restarted, and the widget stayed frozen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The framework behavior underneath it
&lt;/h2&gt;

&lt;p&gt;This is deterministic, and you can confirm it away from the package with a plain &lt;code&gt;AnimationController&lt;/code&gt;. Calling &lt;code&gt;forward()&lt;/code&gt; when &lt;code&gt;value&lt;/code&gt; is already 1.0 does nothing: the status stays completed and &lt;code&gt;isAnimating&lt;/code&gt; stays false. It is a genuine no-op. Calling &lt;code&gt;forward(from: 0)&lt;/code&gt; instead sets &lt;code&gt;value&lt;/code&gt; to 0.0 and &lt;code&gt;isAnimating&lt;/code&gt; to true, and the controller runs again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The change is one line in &lt;code&gt;_syncAnimation&lt;/code&gt;. The branch before 0.2.2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAnimating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forward&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;And after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAnimating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A finished finite loop parks the controller at the upper bound, where&lt;/span&gt;
    &lt;span class="c1"&gt;// forward() would be a no-op; restart from 0 so a raised loop count&lt;/span&gt;
    &lt;span class="c1"&gt;// sweeps again. Otherwise (e.g. re-enabled mid-sweep) resume in place.&lt;/span&gt;
    &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;from:&lt;/span&gt; &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isCompleted&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&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 ternary picks a starting point based on where the controller is parked.&lt;/p&gt;

&lt;p&gt;When the controller is completed, meaning it is parked at the upper bound after a finished finite loop, &lt;code&gt;forward(from: 0)&lt;/code&gt; restarts it from the beginning. That is the case the old code got wrong, and it matches what the status listener already does between loops.&lt;/p&gt;

&lt;p&gt;When the controller is not completed, for example it was stopped mid-sweep because the widget was disabled and is now being re-enabled, &lt;code&gt;forward(from: _controller.value)&lt;/code&gt; starts from the value the controller already holds. Setting the start to the current value is a no-op assignment, and the controller forwards from there. For that path the behavior is identical to the old bare &lt;code&gt;forward()&lt;/code&gt;. Only the completed case changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two behaviors that had to keep working
&lt;/h2&gt;

&lt;p&gt;First, toggling the widget's &lt;code&gt;enabled&lt;/code&gt; flag off and back on in the middle of a sweep resumes in place rather than jumping to the start. A controller stopped mid-sweep is not completed, so the fix sends it down the resume-in-place branch, &lt;code&gt;forward(from: _controller.value)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Second, reduced motion still holds. When the platform's &lt;code&gt;disableAnimations&lt;/code&gt; accessibility setting is on, the shimmer freezes on its base color. That path calls &lt;code&gt;_controller.stop()&lt;/code&gt;, and the fix does not touch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The regression test
&lt;/h2&gt;

&lt;p&gt;A regression test shipped with the fix for exactly this scenario. It mounts the widget with &lt;code&gt;loop: 2&lt;/code&gt; and pumps until the animation settles. Once the finite loop is done the controller stops, so the transient callback count, the number of one-shot callbacks registered for the next frame, falls back to 0. The test then rebuilds with &lt;code&gt;loop: 4&lt;/code&gt; and checks that the sweep resumes, which shows up as the transient callback count going above 0 again.&lt;/p&gt;

&lt;p&gt;On the pre-fix code the count stays at 0, because the frozen controller schedules no ticker. After the fix it goes back above 0. I confirmed this by reverting only the fix and rerunning: the test fails before and passes after.&lt;/p&gt;

&lt;h2&gt;
  
  
  An AnimationController parked at a bound is a quiet trap
&lt;/h2&gt;

&lt;p&gt;Calling &lt;code&gt;forward()&lt;/code&gt; toward the bound you are already at is a no-op, and so is &lt;code&gt;reverse()&lt;/code&gt; toward the bound you are already at, because the remaining distance is zero. When you want the controller to run again from that bound, you have to say so: &lt;code&gt;forward(from: 0)&lt;/code&gt; from the upper bound, or &lt;code&gt;reverse(from: 1)&lt;/code&gt; from the lower bound.&lt;/p&gt;

&lt;p&gt;The other half of this bug sits in &lt;code&gt;didUpdateWidget&lt;/code&gt;. When a runtime parameter change resets internal state, here the completed-loops counter, it has to re-establish the animation to match the new state. Resuming a controller and restarting it are different operations, and the code has to choose the right one for where the controller is parked at that moment.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>animation</category>
    </item>
    <item>
      <title>A route could write index.html outside the output directory: a path traversal fix in flutter_prerender 0.3.2</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 17:52:10 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/a-route-could-write-indexhtml-outside-the-output-directory-a-path-traversal-fix-in-2o9</link>
      <guid>https://dev.to/yusufihsangorgel/a-route-could-write-indexhtml-outside-the-output-directory-a-path-traversal-fix-in-2o9</guid>
      <description>&lt;p&gt;flutter_prerender is a small CLI I maintain on pub.dev. Point it at a &lt;code&gt;flutter build web&lt;/code&gt; output and it loads each route in headless Chrome, reads the text out of Flutter's accessibility tree, and writes one static HTML document per route, so crawlers that do not run JavaScript get real content instead of an empty page. Each route is written to &lt;code&gt;&amp;lt;out&amp;gt;/&amp;lt;route&amp;gt;/index.html&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Version 0.3.2, published today, fixes a bug in the step that turns a route into that path. A crafted route could make the writer create &lt;code&gt;index.html&lt;/code&gt; outside the output directory, in one case at an absolute path with the output directory dropped entirely. That is an arbitrary file write. It was reachable from a plain routes file or a YAML config, not only from &lt;code&gt;--crawl&lt;/code&gt;, and it had been present since 0.1.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  From a route to a file path
&lt;/h2&gt;

&lt;p&gt;Every route, wherever it comes from, goes through one function, &lt;code&gt;normalizeRoute&lt;/code&gt;, and then the writer. The writer does, in effect:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;strip the leading slash from the route to get a relative path,&lt;/li&gt;
&lt;li&gt;join it onto the output directory with &lt;code&gt;path.join&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;create that directory,&lt;/li&gt;
&lt;li&gt;write &lt;code&gt;index.html&lt;/code&gt; into it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Routes come from three places: a routes file, a YAML config, or same-origin links discovered with &lt;code&gt;--crawl&lt;/code&gt;. All three pass through &lt;code&gt;normalizeRoute&lt;/code&gt; and then the writer, so anything &lt;code&gt;normalizeRoute&lt;/code&gt; fails to catch reaches the write step.&lt;/p&gt;

&lt;p&gt;Before 0.3.2, &lt;code&gt;normalizeRoute&lt;/code&gt; only made sure a route began with a leading slash. It prepended one when a route did not have it, and otherwise left the route alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the routes escaped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;..&lt;/code&gt; segment.&lt;/strong&gt; Passing &lt;code&gt;../secret&lt;/code&gt;, &lt;code&gt;normalizeRoute&lt;/code&gt; prepended a slash and returned &lt;code&gt;/../secret&lt;/code&gt;. The writer stripped the leading slash back to &lt;code&gt;../secret&lt;/code&gt;, and &lt;code&gt;path.join(outDir, '../secret')&lt;/code&gt; climbs one directory above &lt;code&gt;outDir&lt;/code&gt;, so &lt;code&gt;index.html&lt;/code&gt; is written outside the directory you chose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A leading &lt;code&gt;//&lt;/code&gt;.&lt;/strong&gt; Passing &lt;code&gt;//etc/passwd&lt;/code&gt;, the route already began with a slash, so &lt;code&gt;normalizeRoute&lt;/code&gt; returned it unchanged. The writer stripped one leading slash, leaving &lt;code&gt;/etc/passwd&lt;/code&gt;, which is an absolute path. &lt;code&gt;path.join(outDir, '/etc/passwd')&lt;/code&gt; returns &lt;code&gt;/etc/passwd&lt;/code&gt;: &lt;code&gt;path.join&lt;/code&gt; discards everything before an absolute segment, so &lt;code&gt;outDir&lt;/code&gt; is dropped and the write lands at an absolute location.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbopz89qpgbpuudnt8itl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbopz89qpgbpuudnt8itl.png" alt="Three routes flowing through strip-leading-slash then path.join(outDir, relative): a normal route lands inside the output directory, a dot-dot route climbs one level above it, and a double-slash route resolves to an absolute path that discards the output directory" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a leading-slash check let them through
&lt;/h2&gt;

&lt;p&gt;A leading-slash check looks at the front of the string. &lt;code&gt;/../secret&lt;/code&gt; starts with exactly one slash and passes. &lt;code&gt;//etc/passwd&lt;/code&gt; starts with a slash and passes. Neither the interior &lt;code&gt;..&lt;/code&gt; nor the second leading slash is something a leading-slash rule examines. Both problems live past the first character.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix in 0.3.2
&lt;/h2&gt;

&lt;p&gt;Three parts: reject the dangerous shapes for explicit routes, skip them for crawled links, and check containment at the write boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Reject the dangerous shapes in normalizeRoute
&lt;/h3&gt;

&lt;p&gt;For routes the user wrote down, in a routes file or a YAML config, a bad shape is a mistake to fix, so &lt;code&gt;normalizeRoute&lt;/code&gt; now throws &lt;code&gt;ConfigException&lt;/code&gt;. This code runs at the end of the function, after the leading slash has been normalized into a local &lt;code&gt;route&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;  &lt;span class="c1"&gt;// A route is written to disk as a path under the output directory, so it&lt;/span&gt;
  &lt;span class="c1"&gt;// must not be able to climb out of it. A .. segment would write above the&lt;/span&gt;
  &lt;span class="c1"&gt;// output directory; a leading // is an absolute path that path.join would&lt;/span&gt;
  &lt;span class="c1"&gt;// honour, discarding the output directory entirely.&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'//'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;ConfigException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Route must not start with "//": "&lt;/span&gt;&lt;span class="si"&gt;$value&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$where&lt;/span&gt;&lt;span class="s"&gt;'&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="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'..'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;ConfigException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Route must not contain a ".." segment: "&lt;/span&gt;&lt;span class="si"&gt;$value&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$where&lt;/span&gt;&lt;span class="s"&gt;'&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="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message quotes the route and adds the source location when it came from a numbered line in a file.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Skip a bad crawled link instead of aborting the crawl
&lt;/h3&gt;

&lt;p&gt;Discovered links are different. One page on the site linking to an odd &lt;code&gt;href&lt;/code&gt; should not stop the whole crawl, so the crawl path, &lt;code&gt;sameOriginRoute&lt;/code&gt;, catches the &lt;code&gt;ConfigException&lt;/code&gt; and skips that link, the same as it skips any other link it cannot turn into a route. It returns &lt;code&gt;null&lt;/code&gt; for a link it will not follow.&lt;/p&gt;

&lt;p&gt;A crawled link reaches &lt;code&gt;normalizeRoute&lt;/code&gt; after one extra step. It is parsed with &lt;code&gt;Uri&lt;/code&gt; first, and &lt;code&gt;Uri&lt;/code&gt; applies RFC 3986 dot-segment removal. An absolute link like &lt;code&gt;/../secret&lt;/code&gt; is collapsed by &lt;code&gt;Uri&lt;/code&gt; to &lt;code&gt;/secret&lt;/code&gt; before &lt;code&gt;normalizeRoute&lt;/code&gt; ever sees it, because &lt;code&gt;..&lt;/code&gt; cannot climb above the root of an absolute path. Only a relative traversal like &lt;code&gt;../secret&lt;/code&gt; keeps its &lt;code&gt;..&lt;/code&gt;, reaches &lt;code&gt;normalizeRoute&lt;/code&gt;, throws, and gets skipped.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A containment check at the write boundary
&lt;/h3&gt;

&lt;p&gt;The last part does not rely on the route being well formed. Before writing, the writer resolves the full target path and refuses to write unless the target is the output directory or inside it. Here &lt;code&gt;p&lt;/code&gt; is &lt;code&gt;package:path/path.dart&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relative&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="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;outDir&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="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isWithin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;ConfigException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s"&gt;'Refusing to write route "&lt;/span&gt;&lt;span class="si"&gt;$route&lt;/span&gt;&lt;span class="s"&gt;" outside the output directory.'&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;&lt;code&gt;p.normalize&lt;/code&gt; resolves any &lt;code&gt;..&lt;/code&gt; in the joined path first, so the comparison is on the real location, and &lt;code&gt;p.isWithin(outDir, target)&lt;/code&gt; is the containment test. This is defense in depth: even if some future route source skipped &lt;code&gt;normalizeRoute&lt;/code&gt;, nothing gets written outside &lt;code&gt;--out&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the functions do now
&lt;/h2&gt;

&lt;p&gt;Both are exported from &lt;code&gt;package:flutter_prerender/flutter_prerender.dart&lt;/code&gt;. &lt;code&gt;normalizeRoute(String raw, {int? lineNumber})&lt;/code&gt; returns the normalized route or throws &lt;code&gt;ConfigException&lt;/code&gt;. &lt;code&gt;sameOriginRoute(String href, {String? origin})&lt;/code&gt; returns a route string, or &lt;code&gt;null&lt;/code&gt; for a link it will not follow. &lt;code&gt;ConfigException&lt;/code&gt; is an &lt;code&gt;Exception&lt;/code&gt; with a &lt;code&gt;String message&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The behavior on 0.3.2, as a test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter_prerender/flutter_prerender.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:test/test.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'explicit routes are validated'&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="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;normalizeRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'/about'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;'/about'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;normalizeRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'../secret'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;throwsA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isA&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ConfigException&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()));&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;normalizeRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'//etc/passwd'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;throwsA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isA&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ConfigException&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'crawled links are filtered'&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="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sameOriginRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'../secret'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// relative traversal, skipped&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sameOriginRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'/../secret'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;'/secret'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// absolute, Uri collapses the ..&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sameOriginRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'about'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;'/about'&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;h2&gt;
  
  
  What still works: safe absolute links
&lt;/h2&gt;

&lt;p&gt;The fix targets two specific shapes, so ordinary routes are unaffected, including a safe absolute link that happens to contain &lt;code&gt;..&lt;/code&gt;. A link like &lt;code&gt;/../about&lt;/code&gt; is absolute, so &lt;code&gt;Uri&lt;/code&gt; collapses it to &lt;code&gt;/about&lt;/code&gt; before &lt;code&gt;normalizeRoute&lt;/code&gt; sees it, the same rule that turns &lt;code&gt;/../secret&lt;/code&gt; into &lt;code&gt;/secret&lt;/code&gt;. Since &lt;code&gt;..&lt;/code&gt; cannot climb above the root of an absolute path, the link stays a normal in-site route. What gets rejected is a relative &lt;code&gt;../secret&lt;/code&gt; and a literal leading &lt;code&gt;//&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When external input becomes a path
&lt;/h2&gt;

&lt;p&gt;Turning external input into a filesystem path has two traps a leading-slash check does not catch.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;..&lt;/code&gt; segment climbs out of the directory you meant to confine writes to. And &lt;code&gt;path.join(base, x)&lt;/code&gt; does not always return something under &lt;code&gt;base&lt;/code&gt;: if &lt;code&gt;x&lt;/code&gt; is absolute, &lt;code&gt;join&lt;/code&gt; returns &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;base&lt;/code&gt; is gone.&lt;/p&gt;

&lt;p&gt;The fix is two layers that do not depend on each other. Validate the logical route by rejecting &lt;code&gt;..&lt;/code&gt; segments and a leading &lt;code&gt;//&lt;/code&gt;, then check containment at the write boundary with &lt;code&gt;p.isWithin&lt;/code&gt;, so the write cannot land outside the directory no matter how the path was assembled. This is the same shape as classic web path traversal, here in a Dart CLI that writes files.&lt;/p&gt;

&lt;p&gt;flutter_prerender 0.3.2 is on pub.dev.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A resolved top-level null was dropped from streamPartialJson (stream_struct 0.3.2)</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 17:16:02 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/a-resolved-top-level-null-was-dropped-from-streampartialjson-streamstruct-032-4g5g</link>
      <guid>https://dev.to/yusufihsangorgel/a-resolved-top-level-null-was-dropped-from-streampartialjson-streamstruct-032-4g5g</guid>
      <description>&lt;p&gt;&lt;code&gt;stream_struct&lt;/code&gt; takes a stream of text deltas from a model and turns it into a stream of parsed JSON values. The entry point is &lt;code&gt;streamPartialJson&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;streamPartialJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It accumulates the deltas into a buffer and, after each one, tries to parse the buffer so far. When the buffer parses, it emits the value. It skips deltas that leave the buffer unparseable (a half-written token or object) or whose parsed value matches the last one emitted. A listener sees the object grow as the deltas arrive.&lt;/p&gt;

&lt;p&gt;That much worked. The bug was in how the loop decided a delta had produced a value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;streamPartialJson&lt;/code&gt; was built on the exported helper &lt;code&gt;parsePartialJson&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;parsePartialJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;buffer&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;parsePartialJson&lt;/code&gt; returns &lt;code&gt;Object?&lt;/code&gt;, and it returns &lt;code&gt;null&lt;/code&gt; in two different situations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Nothing is decodable yet. The buffer is a partial token like &lt;code&gt;"nu"&lt;/code&gt;, or a half-written object.&lt;/li&gt;
&lt;li&gt;The buffer decoded to the JSON literal &lt;code&gt;null&lt;/code&gt;, which is a real, resolved value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The old streaming loop collapsed both into one check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parsePartialJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&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="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a resolved top-level &lt;code&gt;null&lt;/code&gt; hit the same branch as "not ready yet" and was skipped. If the whole answer was &lt;code&gt;null&lt;/code&gt;, the stream completed having emitted nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvyf2s8x773mdlfyjgvo6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvyf2s8x773mdlfyjgvo6.png" alt="The buffer growing from nu to null: parsePartialJson returns null both for the incomplete token and for the decoded literal null, so the old value == null check drops both frames, while the new hasValue check keeps the resolved null" width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing it
&lt;/h2&gt;

&lt;p&gt;Here is a model that streams the JSON literal &lt;code&gt;null&lt;/code&gt; across two deltas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:stream_struct/stream_struct.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// A model that streams the JSON literal null across two deltas.&lt;/span&gt;
  &lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="s"&gt;'nu'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="s"&gt;'ll'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;await&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;final&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;streamPartialJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// stream_struct 0.3.1: []      the resolved null was dropped, no frame emitted.&lt;/span&gt;
  &lt;span class="c1"&gt;// stream_struct 0.3.2: [null]  the null is emitted once, as a resolved value.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 0.3.1 the first delta &lt;code&gt;"nu"&lt;/code&gt; does not parse, so &lt;code&gt;parsePartialJson&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; and the loop skips it, which is correct. The second delta completes the buffer to &lt;code&gt;null&lt;/code&gt;, which parses to &lt;code&gt;null&lt;/code&gt;, which the loop reads as "not ready" and also skips. The stream ends with an empty list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;A model's structured answer can legitimately be a top-level &lt;code&gt;null&lt;/code&gt;: a nullable result, an optional tool output, or an explicit "no value". A consumer waiting for a frame for that answer waits forever, because the one frame that would carry the value is the one the loop skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope: this was only ever the top level
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;null&lt;/code&gt; nested inside an object or array was never affected. The container parses to a non-null value, so the loop emits it and the nested &lt;code&gt;null&lt;/code&gt; rides along inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="s"&gt;'{"a":'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="s"&gt;'null}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// streamPartialJson(obj()) emits: [{}, {a: null}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partial buffer &lt;code&gt;{"a":&lt;/code&gt; produces an empty map as the first frame. The second delta completes the object to &lt;code&gt;{"a": null}&lt;/code&gt;, which parses to &lt;code&gt;{a: null}&lt;/code&gt; and is emitted as the second frame. Both frames are non-null maps, so the &lt;code&gt;value == null&lt;/code&gt; check never fired. The bug required the top-level value itself to be &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;0.3.2 adds an internal record type that carries presence separately from the value. &lt;code&gt;parsePartialJsonResult&lt;/code&gt; returns a record &lt;code&gt;(bool hasValue, Object? value)&lt;/code&gt;. "Nothing decodable yet" is &lt;code&gt;hasValue&lt;/code&gt; false. "Decoded to null" is &lt;code&gt;hasValue&lt;/code&gt; true with &lt;code&gt;value&lt;/code&gt; null.&lt;/p&gt;

&lt;p&gt;The diff in &lt;code&gt;lib/src/streaming.dart&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parsePartialJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&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="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&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="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;lastEncoded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;lastEncoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parsePartialJsonResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&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="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&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="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;lastEncoded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;lastEncoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop now checks &lt;code&gt;hasValue&lt;/code&gt; to decide whether a delta produced a value, and reads &lt;code&gt;result.value&lt;/code&gt; for the value itself. A decoded &lt;code&gt;null&lt;/code&gt; has &lt;code&gt;hasValue&lt;/code&gt; true, so it passes the check, flows through the change detection, and reaches the &lt;code&gt;yield&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public API did not change
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;parsePartialJson&lt;/code&gt; keeps its exact &lt;code&gt;Object?&lt;/code&gt; contract. The new &lt;code&gt;parsePartialJsonResult&lt;/code&gt; and its &lt;code&gt;PartialJsonResult&lt;/code&gt; type are &lt;code&gt;@internal&lt;/code&gt; and not exported. Nothing in the public surface changed, which is why 0.3.2 is a patch bump from 0.3.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests that shipped
&lt;/h2&gt;

&lt;p&gt;The regression tests in 0.3.2 cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a top-level &lt;code&gt;null&lt;/code&gt; streamed across two deltas emits a single &lt;code&gt;[null]&lt;/code&gt; frame,&lt;/li&gt;
&lt;li&gt;a top-level &lt;code&gt;null&lt;/code&gt; arriving in one delta also emits &lt;code&gt;[null]&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;ordinary object growth and a never-resolving buffer still behave as before.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The root cause: null doing double duty
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;null&lt;/code&gt; was carrying two meanings on the same value. As the return of &lt;code&gt;parsePartialJson&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt; meant "not ready yet"; as a decoded JSON value, &lt;code&gt;null&lt;/code&gt; meant "the answer is null". An &lt;code&gt;Object?&lt;/code&gt; cannot tell those apart, because they are the same runtime value, so any caller that has to distinguish them is stuck.&lt;/p&gt;

&lt;p&gt;The fix gives presence its own field. A &lt;code&gt;(bool hasValue, Object? value)&lt;/code&gt; record has a &lt;code&gt;hasValue&lt;/code&gt; field for "is there a value at all" and a separate &lt;code&gt;value&lt;/code&gt; field for "what is it", so "no value yet" and "the value is null" stop sharing one representation. That is the same shape as an &lt;code&gt;Option&lt;/code&gt;, or any two-field result: worth reaching for whenever a sentinel you return can also be a legitimate value. Here both were &lt;code&gt;null&lt;/code&gt;, which is why they collided.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>ai</category>
      <category>llm</category>
      <category>json</category>
    </item>
    <item>
      <title>rag_kit stored the caller's metadata map by reference, then handed it back from search</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 16:36:41 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/ragkit-stored-the-callers-metadata-map-by-reference-then-handed-it-back-from-search-4nmg</link>
      <guid>https://dev.to/yusufihsangorgel/ragkit-stored-the-callers-metadata-map-by-reference-then-handed-it-back-from-search-4nmg</guid>
      <description>&lt;p&gt;rag_kit is a small on-device retrieval toolkit for Dart and Flutter. Its &lt;code&gt;InMemoryVectorStore&lt;/code&gt; holds &lt;code&gt;Document&lt;/code&gt; values and answers similarity searches over their embeddings. Version 0.3.1, published today, fixes an aliasing bug in how that store kept each document's metadata map.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API in play
&lt;/h2&gt;

&lt;p&gt;The public shape of a &lt;code&gt;Document&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;metadata&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;InMemoryVectorStore&lt;/code&gt; exposes two methods this post uses: &lt;code&gt;Future&amp;lt;void&amp;gt; upsert(List&amp;lt;Document&amp;gt; documents)&lt;/code&gt; to add or replace documents, and &lt;code&gt;Future&amp;lt;List&amp;lt;ScoredChunk&amp;gt;&amp;gt; search(List&amp;lt;double&amp;gt; query, {int topK = 5, double? minScore, bool Function(Document)? where})&lt;/code&gt; to query. Each &lt;code&gt;ScoredChunk&lt;/code&gt; in the result has a &lt;code&gt;document&lt;/code&gt; and a &lt;code&gt;score&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;InMemoryVectorStore&lt;/code&gt; built each stored entry by carrying the incoming document's fields across, including the metadata map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nl"&gt;metadata:&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That stored the same &lt;code&gt;Map&lt;/code&gt; instance the caller passed to &lt;code&gt;upsert&lt;/code&gt;. The store held a reference to the caller's map. Two things follow from that.&lt;/p&gt;

&lt;p&gt;First, editing the caller's own map after &lt;code&gt;upsert&lt;/code&gt; returns changes what the store holds. There is no second call into the store. The write goes through the caller's reference, and the store observes it because it is the same object.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;InMemoryVectorStore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;{&lt;/span&gt;&lt;span class="s"&gt;'status'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'draft'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="n"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="s"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;text:&lt;/span&gt; &lt;span class="s"&gt;'hello'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;embedding:&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;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nl"&gt;metadata:&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// The caller edits its own map, long after the upsert call returned.&lt;/span&gt;
&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;search&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;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;single&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;document&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// rag_kit 0.3.0: prints "published". The later edit reached the store.&lt;/span&gt;
&lt;span class="c1"&gt;// rag_kit 0.3.1: prints "draft". The store kept its own copy.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, &lt;code&gt;search&lt;/code&gt; returns the stored &lt;code&gt;Document&lt;/code&gt;, and its &lt;code&gt;metadata&lt;/code&gt; is that same map. A caller that reads a result and writes to its metadata is writing into the store. In 0.3.0 a search result is a live handle into the store's internal state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;search&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;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;single&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;document&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'archived'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// rag_kit 0.3.0: silently rewrote the stored document.&lt;/span&gt;
&lt;span class="c1"&gt;// rag_kit 0.3.1: throws UnsupportedError (the returned map is unmodifiable).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure in 0.3.0 is silent. Nothing throws; the store's data changes and the next &lt;code&gt;search&lt;/code&gt; returns the changed value.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4p2ca9o42dd213c8kqdd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4p2ca9o42dd213c8kqdd.png" alt="A caller's map, the stored entry, and the search result all pointing at one shared Map instance before the fix, versus the store holding its own unmodifiable top-level copy after it" width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Version 0.3.1 changes the one line that built the stored entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;span class="nl"&gt;metadata:&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="nl"&gt;metadata:&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unmodifiable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;metadata&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;Map.unmodifiable&lt;/code&gt; does two things here. It allocates a new top-level map, so the store no longer holds the caller's instance, and later edits to the caller's map do not reach the store. It also makes the stored map reject writes, so writing back through a search result throws &lt;code&gt;UnsupportedError&lt;/code&gt; at the point of the write, rather than changing the store quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fix does not do
&lt;/h2&gt;

&lt;p&gt;The copy is top level only. &lt;code&gt;Map.unmodifiable&lt;/code&gt; copies only the outer map; it does not recurse into the values. If a metadata value is itself a &lt;code&gt;List&lt;/code&gt; or a &lt;code&gt;Map&lt;/code&gt;, that inner collection is still the caller's object, shared and mutable. A write into a nested collection can still reach the store, and it will not throw, because only the outer map is unmodifiable, not its values.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Document&lt;/code&gt; dartdoc now says so: "a store may copy the top level of metadata, but is not expected to deep-copy its values." If you keep mutable collections inside metadata and need them isolated, copy them yourself before putting them in a &lt;code&gt;Document&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The regression tests
&lt;/h2&gt;

&lt;p&gt;Two regression tests shipped in &lt;code&gt;test/vector_store_test.dart&lt;/code&gt;. The first upserts a &lt;code&gt;Document&lt;/code&gt; with a metadata map, mutates the caller's map after the upsert, runs a search, and asserts the result still carries the original value. The second takes a search result, writes to its &lt;code&gt;metadata&lt;/code&gt;, asserts that write throws &lt;code&gt;UnsupportedError&lt;/code&gt;, and asserts a following search shows the store unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy at the boundary
&lt;/h2&gt;

&lt;p&gt;This root cause shows up outside vector search too. Any store, cache, or index that keeps a reference to a caller-supplied mutable collection is aliasing it: the caller and the store share one object, and either side can change what the other sees. Handing that same instance back out, the way &lt;code&gt;search&lt;/code&gt; did, compounds the surprise, because a read result becomes a live handle into internal state.&lt;/p&gt;

&lt;p&gt;The fix is to copy the collection where it enters the store. Copying breaks the alias, so a caller's later edits stay with the caller. Making the copy unmodifiable adds the second half: a write that used to change stored state quietly now throws where it happens, next to the code that made the mistake. The cost is one top-level map copy per stored document, and an exception on a write path that was already a bug.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>programming</category>
    </item>
    <item>
      <title>Check.isValidJson Failed on Correct JSON When the Model Wrapped It in a Markdown Fence</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 14:14:38 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/checkisvalidjson-failed-on-correct-json-when-the-model-wrapped-it-in-a-markdown-fence-o0e</link>
      <guid>https://dev.to/yusufihsangorgel/checkisvalidjson-failed-on-correct-json-when-the-model-wrapped-it-in-a-markdown-fence-o0e</guid>
      <description>&lt;p&gt;&lt;code&gt;llm_eval&lt;/code&gt; is a small Dart package for regression-testing LLM output: you write checks against a model's response, run them in CI, and read the outcome off two independent fields, &lt;code&gt;passed&lt;/code&gt; and &lt;code&gt;isError&lt;/code&gt;. &lt;code&gt;Check.isValidJson&lt;/code&gt; is one of two checks shown in the README's Quick Start, and it exists for structured-output regression testing.&lt;/p&gt;

&lt;p&gt;Until today's 0.3.1 release, that check called &lt;code&gt;jsonDecode&lt;/code&gt; directly on the raw model output. When the model wrapped its JSON answer in a markdown code fence, which every mainstream chat-tuned model does by default, the check reported a failure even though the answer was correct and the JSON inside the fence was valid. The harness did not recognize the fence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repro
&lt;/h2&gt;

&lt;p&gt;I ran this against a real model rather than a stub: &lt;code&gt;ollama serve&lt;/code&gt; locally with &lt;code&gt;llama3.2:3b&lt;/code&gt;, the same model &lt;code&gt;test/ollama_e2e_test.dart&lt;/code&gt; already uses for end-to-end tests in this package. Prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What is the capital of France? Answer with a JSON object containing a city field, formatted as a code block.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response:&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="err"&gt;```json&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;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Paris"&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="err"&gt;```&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;This&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;capital&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;France.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the right answer, and the JSON inside the fence is valid. I fed the exact string through the package at 0.3.0 as a path dependency, with no mocking involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;passed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;isError&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;detail: not valid JSON&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Unexpected character&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same result with the README's own quick-start example, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Check&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isValidJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;where:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;containsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;against&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="err"&gt;```json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Paris"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;```&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;passed: false&lt;/code&gt;. The example in the README that is supposed to demonstrate the check did not work against a real model's default output format.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft17efm5v8vv97hrspafq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft17efm5v8vv97hrspafq.png" alt="Flow through evaluate(): the raw jsonDecode runs first and succeeds directly for unfenced JSON, while fenced input throws a FormatException and falls through to the _fencedBlock regex, which extracts the inner content and decodes it into a pass carrying the detail 'parsed after stripping a markdown code fence'"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why isError: false is the part that matters
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;llm_eval&lt;/code&gt; keeps &lt;code&gt;passed&lt;/code&gt; and &lt;code&gt;isError&lt;/code&gt; as separate fields on purpose. The doc comment on &lt;code&gt;CheckResult&lt;/code&gt; and the README both say the split exists so a broken harness is not mistaken for a failing model. A &lt;code&gt;where&lt;/code&gt; callback that throws, for example, routes through &lt;code&gt;describeError&lt;/code&gt; in &lt;code&gt;lib/src/check.dart&lt;/code&gt; and comes back as an error result rather than a fail.&lt;/p&gt;

&lt;p&gt;The fenced-JSON case is exactly the scenario that split is meant to catch, and it slipped through it. The harness was under-featured, and the result it reported, &lt;code&gt;passed: false, isError: false&lt;/code&gt;, means the model got the answer wrong. The model had answered correctly; the check was the thing that failed.&lt;/p&gt;

&lt;p&gt;For anyone running this in CI against a real endpoint, that is not a one-off. Every run where the model wraps its answer in a fence, the default for most chat models unless you force a JSON-only response mode, produces a false failure. That is noise, in a package whose reason for existing is separating real regressions from harness noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintainer already knew, informally
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;test/ollama_e2e_test.dart&lt;/code&gt;, line 79, prompts the model with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...No prose, no code fences.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That instruction is in the test suite because I had already hit this and worked around it by asking the model not to do the thing it does by default. It never made it into the library. Anyone using &lt;code&gt;Check.isValidJson&lt;/code&gt; against a real model, without independently discovering and copying that same prompt wording, gets chronic spurious failures, with nothing in the output pointing at the prompt as the thing to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;lib/src/check.dart&lt;/code&gt;, before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;CheckResult&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonDecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;FormatException&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&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="n"&gt;CheckResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;detail:&lt;/span&gt; &lt;span class="s"&gt;'not valid JSON: &lt;/span&gt;&lt;span class="si"&gt;${e.message}&lt;/span&gt;&lt;span class="s"&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;final&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_where&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="n"&gt;where&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CheckResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;RegExp&lt;/span&gt; &lt;span class="n"&gt;_fencedBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sx"&gt;r'```(?:json)?\s*\n?(.*?)\n?```'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;dotAll:&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="n"&gt;CheckResult&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;viaFence&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;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonDecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;FormatException&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_fencedBlock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;firstMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&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="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&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="n"&gt;CheckResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;detail:&lt;/span&gt; &lt;span class="s"&gt;'not valid JSON: &lt;/span&gt;&lt;span class="si"&gt;${e.message}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonDecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;group&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="o"&gt;!.&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="n"&gt;viaFence&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="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;FormatException&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e2&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="n"&gt;CheckResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;detail:&lt;/span&gt; &lt;span class="s"&gt;'not valid JSON: &lt;/span&gt;&lt;span class="si"&gt;${e2.message}&lt;/span&gt;&lt;span class="s"&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;passDetail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;viaFence&lt;/span&gt;
      &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;'parsed after stripping a markdown code fence'&lt;/span&gt;
      &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_where&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="n"&gt;where&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;CheckResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;detail:&lt;/span&gt; &lt;span class="n"&gt;passDetail&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...where handling unchanged, now returns CheckResult.pass(detail: passDetail) on success&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unfenced fast path is untouched: raw &lt;code&gt;jsonDecode&lt;/code&gt; runs first, and correct unfenced JSON never reaches the regex. Only on a &lt;code&gt;FormatException&lt;/code&gt; does it look for a single fenced block, optionally tagged &lt;code&gt;json&lt;/code&gt;, with &lt;code&gt;dotAll&lt;/code&gt; so the fence can span lines, and try decoding the captured content.&lt;/p&gt;

&lt;p&gt;There is one genuinely new failure branch: a fence that matches but whose contents still do not parse, caught as &lt;code&gt;e2&lt;/code&gt;. It returns the same &lt;code&gt;not valid JSON:&lt;/code&gt; message shape as the original path, so the failure text a user sees is unchanged even though the control flow reaching it did not exist before.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;detail&lt;/code&gt; field on a pass reached through the fallback reads &lt;code&gt;'parsed after stripping a markdown code fence'&lt;/code&gt;. The fix avoids silently accepting fenced JSON and presenting the input as clean. A report from this path records which route decoded the value, so when you are working out why a check passed, you can see that it went through the fence-stripping branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;test/check_test.dart&lt;/code&gt; now covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;passes on a fenced block tagged &lt;code&gt;json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;passes on a plain fenced block with no language tag&lt;/li&gt;
&lt;li&gt;passes on the exact repro shape: fenced JSON followed by trailing prose after the closing fence&lt;/li&gt;
&lt;li&gt;confirms the &lt;code&gt;where&lt;/code&gt; callback receives the value decoded from inside the fence rather than the raw string&lt;/li&gt;
&lt;li&gt;fails when neither the raw string nor the fenced content parses as JSON&lt;/li&gt;
&lt;li&gt;a regression guard: plain non-JSON prose with no fence still fails exactly as before&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix adds a fallback path and leaves the definition of valid JSON alone. Prose with no fence and no JSON in it still fails the way it always did.&lt;/p&gt;

&lt;p&gt;I also checked it the boring way: &lt;code&gt;git stash&lt;/code&gt; on the fix file alone, rerun the repro, &lt;code&gt;passed: false&lt;/code&gt;, the original bug. Restore the stash, rerun, &lt;code&gt;passed: true&lt;/code&gt;. Full round trip, no drift in the diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you are writing similar checks
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Check.isValidJson&lt;/code&gt; was written against the JSON spec: does this string parse as JSON. It was not written against how models that emit JSON actually respond when you do not constrain them, which is markdown-formatted prose with a fenced block inside it.&lt;/p&gt;

&lt;p&gt;Any check you write against "the model's output" has both of those as inputs: the format your check expects, and the format the model tends to produce when you have not forced it otherwise. A README example that looks obviously correct can still fail the moment you point it at a real endpoint, because the gap sits in the shape of string that actually arrives rather than in the check's logic.&lt;/p&gt;

&lt;p&gt;0.3.1 is on pub.dev now.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
