<?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: mohammad rostami</title>
    <description>The latest articles on DEV Community by mohammad rostami (@mohammad_rostami_).</description>
    <link>https://dev.to/mohammad_rostami_</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%2F3628004%2F2099a6d6-913c-4088-ba3c-9b587f8b8fd4.jpg</url>
      <title>DEV Community: mohammad rostami</title>
      <link>https://dev.to/mohammad_rostami_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohammad_rostami_"/>
    <language>en</language>
    <item>
      <title>One React Native App, Multiple IAP Providers: Architecture, Autolinking and Build Isolation</title>
      <dc:creator>mohammad rostami</dc:creator>
      <pubDate>Fri, 09 Oct 2026 17:49:44 +0000</pubDate>
      <link>https://dev.to/mohammad_rostami_/one-react-native-app-multiple-iap-providers-architecture-autolinking-and-build-isolation-1kin</link>
      <guid>https://dev.to/mohammad_rostami_/one-react-native-app-multiple-iap-providers-architecture-autolinking-and-build-isolation-1kin</guid>
      <description>&lt;p&gt;When I started adding a second in-app purchase provider to a React Native app, the application-level design looked straightforward.&lt;/p&gt;

&lt;p&gt;Both stores needed almost the same things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;load products&lt;/li&gt;
&lt;li&gt;start a purchase&lt;/li&gt;
&lt;li&gt;restore purchases&lt;/li&gt;
&lt;li&gt;check ownership&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SDKs were different, but the business flow was not. So the first step was the obvious one: hide the store-specific code behind a shared interface and let the rest of the app depend on that interface.&lt;/p&gt;

&lt;p&gt;That part worked.&lt;/p&gt;

&lt;p&gt;The problem appeared one layer lower.&lt;/p&gt;

&lt;p&gt;A clean TypeScript provider abstraction does not tell React Native which native modules should exist in the binary. If both store SDKs are installed, autolinking can still discover both of them. At that point the problem is no longer only about choosing the right provider at runtime. It is also about deciding which native billing implementation is allowed into each build.&lt;/p&gt;

&lt;p&gt;That distinction changed the way I structured the integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The provider abstraction worked until the native build
&lt;/h2&gt;

&lt;p&gt;I still wanted the application to see one commerce contract.&lt;/p&gt;

&lt;p&gt;A screen should be able to call &lt;code&gt;purchase()&lt;/code&gt; without knowing which marketplace is underneath it. Entitlement logic should not contain store-specific branches. Product UI should not care which native SDK returned the data.&lt;/p&gt;

&lt;p&gt;A small interface is enough to define that boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Purchase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transactionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;IAPProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;getProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;purchase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Purchase&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;restorePurchases&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Purchase&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each store can adapt its own SDK to that contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StoreAProvider&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;IAPProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Connect to Store A billing.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Map Store A product models to the app's Product shape.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;purchase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Purchase&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Execute the native Store A purchase flow.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;transactionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;store-a-transaction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;restorePurchases&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Purchase&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second provider implements the same interface, and a mock provider can cover development:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MockIAPProvider&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;IAPProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;productIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Mock product &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;purchase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Purchase&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;transactionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`mock-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;restorePurchases&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Purchase&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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;This kept the application code clean, and I would use the same abstraction again.&lt;/p&gt;

&lt;p&gt;What it did not solve was native dependency isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Autolinking changes what "selected provider" means
&lt;/h2&gt;

&lt;p&gt;At the JavaScript level, selecting a provider is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COMMERCE_TARGETS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;storeA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;storeB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CommerceTarget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;COMMERCE_TARGETS&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createIAPProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CommerceTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;IAPProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;storeA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StoreAProvider&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;storeB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StoreBProvider&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MockIAPProvider&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That answers which implementation the application should call.&lt;/p&gt;

&lt;p&gt;React Native still has another decision to make during the native build: which modules should be linked into the binary.&lt;/p&gt;

&lt;p&gt;If both billing SDKs are installed in &lt;code&gt;node_modules&lt;/code&gt;, both are candidates for native discovery. Choosing &lt;code&gt;StoreAProvider&lt;/code&gt; in TypeScript does not automatically remove Store B's package from Gradle, the Android manifest, or native module registration.&lt;/p&gt;

&lt;p&gt;The dependency tree can still look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_modules
├── store-a-iap-sdk
└── store-b-iap-sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the part my first design did not account for.&lt;/p&gt;

&lt;p&gt;I had treated provider selection as a runtime concern, but native billing capability is also a build-time concern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runtime provider selection is not the same as native dependency isolation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two SDKs that look almost identical from JavaScript can still have very different native behavior. They may bring different Gradle dependencies, initialization code, manifest changes, lifecycle assumptions, or React Native compatibility constraints.&lt;/p&gt;

&lt;p&gt;Once both exist in the same binary, all of those differences become part of the release surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the store a build-time concern
&lt;/h2&gt;

&lt;p&gt;The model that worked better was to treat each marketplace build as a native variant of the same application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Store A build
└── Store A native billing SDK

Store B build
└── Store B native billing SDK

Development build
└── Mock commerce provider
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source code stays shared, but the binaries do not need to expose the same native capabilities.&lt;/p&gt;

&lt;p&gt;I keep the build target explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CommerceTarget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;storeA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;storeB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveCommerceTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CommerceTarget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;storeA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;storeB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application uses that value to select the TypeScript provider.&lt;/p&gt;

&lt;p&gt;The native build configuration uses the same target to decide which marketplace SDK is allowed to autolink.&lt;/p&gt;

&lt;p&gt;The exact configuration depends on the React Native and Expo versions in the project, so the useful lesson is not one copy-paste configuration file. The useful part is where the decision is made.&lt;/p&gt;

&lt;p&gt;A Store A build should not simply avoid calling Store B's SDK.&lt;/p&gt;

&lt;p&gt;It should avoid linking Store B's SDK.&lt;/p&gt;

&lt;p&gt;That keeps the binary easier to reason about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no unused billing SDK in the final artifact&lt;/li&gt;
&lt;li&gt;fewer Gradle dependency interactions&lt;/li&gt;
&lt;li&gt;fewer store-specific manifest changes&lt;/li&gt;
&lt;li&gt;less native initialization surface&lt;/li&gt;
&lt;li&gt;clearer release debugging&lt;/li&gt;
&lt;li&gt;fewer questions about which billing lifecycle owns the app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also gives failures a smaller search space. If a Store A build breaks, I want to inspect Store A's native integration, not Store A plus an unrelated billing SDK that should never have been there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture after separating those two decisions
&lt;/h2&gt;

&lt;p&gt;Once runtime provider selection and native build isolation are treated separately, the overall shape becomes much clearer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         React Native UI
                               │
                               ▼
                        Commerce Service
                               │
                               ▼
                       IAP Provider Contract
                          /             \
                         /               \
                Store A Provider   Store B Provider
                        │                 │
                        ▼                 ▼
                  Native SDK A       Native SDK B
                        │                 │
                        ▼                 ▼
                  Store A Build       Store B Build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared contract belongs to the application.&lt;/p&gt;

&lt;p&gt;The native SDK belongs to the binary.&lt;/p&gt;

&lt;p&gt;That separation is the important part.&lt;/p&gt;

&lt;h2&gt;
  
  
  One repository can still produce different native runtimes
&lt;/h2&gt;

&lt;p&gt;This became more important once OTA updates entered the picture.&lt;/p&gt;

&lt;p&gt;If two store builds contain different native billing modules, they are not equivalent runtimes just because they were produced from the same Git repository.&lt;/p&gt;

&lt;p&gt;Consider these two binaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Binary A
└── Native Billing SDK A

Binary B
└── Native Billing SDK B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now publish JavaScript that assumes SDK A exists.&lt;/p&gt;

&lt;p&gt;That JavaScript is valid for Binary A. It may be invalid for Binary B.&lt;/p&gt;

&lt;p&gt;This is where runtime fingerprinting starts to matter beyond being another release setting. Native dependency differences are part of compatibility.&lt;/p&gt;

&lt;p&gt;If a native module changes, or if a build links a different native module entirely, the OTA boundary should reflect that.&lt;/p&gt;

&lt;p&gt;For a multi-store app, I prefer separate update channels, or equivalent release boundaries, for each marketplace build, backed by a runtime policy that includes native changes.&lt;/p&gt;

&lt;p&gt;The rule I use is straightforward:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If two binaries do not have the same native capabilities, they should not automatically receive the same OTA update.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That also changes how I think about "one app."&lt;/p&gt;

&lt;p&gt;From the product side, it is one app.&lt;/p&gt;

&lt;p&gt;From the repository side, it is one codebase.&lt;/p&gt;

&lt;p&gt;From the native release side, it can still be two different binaries with different capabilities and different compatibility boundaries.&lt;/p&gt;

&lt;p&gt;Those statements can all be true at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native patches are part of the build, not a local workaround
&lt;/h2&gt;

&lt;p&gt;The second place where this became obvious was native compatibility patching.&lt;/p&gt;

&lt;p&gt;Sometimes a billing package is close to what the current React Native or Expo version expects, but not quite. A small patch can make the integration work.&lt;/p&gt;

&lt;p&gt;The unsafe version of that approach is a postinstall script that searches for some text and rewrites whatever package version happens to be installed.&lt;/p&gt;

&lt;p&gt;That is convenient until the dependency publishes a new patch version and the same script edits code it has never been reviewed against.&lt;/p&gt;

&lt;p&gt;For native patches, I prefer to be deliberately strict.&lt;/p&gt;

&lt;p&gt;A patch should know which package version it was written for and should refuse to run when that assumption changes.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PatchContract&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;expectedVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;expectedSourceHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;assertPatchContract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PatchContract&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;actualVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;actualSourceHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actualVersion&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expectedVersion&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Review the patch before using &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;actualVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actualSourceHash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expectedSourceHash&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Source changed for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;; refusing to patch it blindly`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also want the patch to be idempotent.&lt;/p&gt;

&lt;p&gt;Running install twice should not produce two different native trees. A clean local install and a clean cloud build should reach the same result.&lt;/p&gt;

&lt;p&gt;This is one of those cases where a failed build is better than a "successful" build with an unreviewed native modification.&lt;/p&gt;

&lt;p&gt;There is another release consequence here: a native patch can change the runtime fingerprint.&lt;/p&gt;

&lt;p&gt;Changing one file inside a native dependency may look tiny in Git, especially if the patch is applied after install. From the binary's point of view, it is still native code changing underneath the JavaScript bundle.&lt;/p&gt;

&lt;p&gt;So I treat native patches as part of the release architecture, not as a developer-machine trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep local and cloud builds boring
&lt;/h2&gt;

&lt;p&gt;Once multiple store variants and native patches are involved, reproducibility matters more than clever build logic.&lt;/p&gt;

&lt;p&gt;I want the following inputs to be explicit and repeatable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lockfile
build target
store configuration
native patch version
environment variables
update channel
runtime fingerprint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A clean install should be meaningful.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;npm ci&lt;/code&gt; on my machine and the dependency install inside the cloud builder produce different native trees, that is a release problem even if the JavaScript bundle is identical.&lt;/p&gt;

&lt;p&gt;This is also where dependency version ranges deserve more attention.&lt;/p&gt;

&lt;p&gt;For most packages, a compatible patch update is exactly what semver is supposed to make easy.&lt;/p&gt;

&lt;p&gt;For a native package coupled to a reviewed compatibility patch, an automatic patch update can be the opposite of what I want. The package manager may consider the new version compatible while the patch script correctly considers it unknown.&lt;/p&gt;

&lt;p&gt;In that situation, pinning the reviewed version is not about being afraid of upgrades. It is about making the upgrade explicit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;install the new version&lt;/li&gt;
&lt;li&gt;inspect the native source&lt;/li&gt;
&lt;li&gt;review or remove the patch&lt;/li&gt;
&lt;li&gt;run the integration tests again&lt;/li&gt;
&lt;li&gt;move the release forward&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is much easier to debug than discovering the change halfway through a store build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the binary, not only the TypeScript abstraction
&lt;/h2&gt;

&lt;p&gt;The provider interface makes unit tests straightforward, but a passing interface test does not prove that the correct native SDK ended up in the APK.&lt;/p&gt;

&lt;p&gt;I treat the release matrix itself as something worth testing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Build&lt;/th&gt;
&lt;th&gt;Native billing capability&lt;/th&gt;
&lt;th&gt;TypeScript provider&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Development&lt;/td&gt;
&lt;td&gt;None / mock&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MockIAPProvider&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store A&lt;/td&gt;
&lt;td&gt;SDK A only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;StoreAProvider&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store B&lt;/td&gt;
&lt;td&gt;SDK B only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;StoreBProvider&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The commerce contract should behave consistently across providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getOwnedProductIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IAPProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;purchases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restorePurchases&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;purchase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;purchase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="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 build target should select the expected provider.&lt;/p&gt;

&lt;p&gt;The native build should contain the expected SDK and exclude the other one.&lt;/p&gt;

&lt;p&gt;And finally, the real purchase flow still needs a release-like device test.&lt;/p&gt;

&lt;p&gt;For each marketplace build, I want to verify at least:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;launch
→ connect to billing
→ load products
→ start a purchase
→ handle cancel or success
→ restore ownership
→ relaunch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mocks are useful for application behavior.&lt;/p&gt;

&lt;p&gt;They cannot prove that Gradle, autolinking, the marketplace client, and the native billing lifecycle agree with each other.&lt;/p&gt;

&lt;p&gt;That proof has to happen on the actual binary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The provider abstraction was still the right first step. It kept marketplace-specific billing code out of the rest of the application and gave the product one stable commerce API.&lt;/p&gt;

&lt;p&gt;The part I had underestimated was the boundary below it.&lt;/p&gt;

&lt;p&gt;Selecting a provider in TypeScript is an application decision. Deciding which billing SDK exists in the binary is a build decision. OTA compatibility, native patches, and release testing all follow from that difference.&lt;/p&gt;

&lt;p&gt;For the next multi-store React Native app, I would structure it the same way: keep the purchase contract shared, make the marketplace an explicit build target, and let each binary contain only the native billing capability it actually needs.&lt;/p&gt;

&lt;p&gt;That keeps the cross-platform part genuinely shared without pretending the native part is identical when it is not.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>typescript</category>
      <category>mobile</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Reliable Form Submissions: Dirty State, Fingerprints, and Idempotency</title>
      <dc:creator>mohammad rostami</dc:creator>
      <pubDate>Fri, 21 Aug 2026 07:07:30 +0000</pubDate>
      <link>https://dev.to/mohammad_rostami_/meaningful-dirty-state-fingerprints-and-idempotency-three-details-that-make-form-submissions-36e0</link>
      <guid>https://dev.to/mohammad_rostami_/meaningful-dirty-state-fingerprints-and-idempotency-three-details-that-make-form-submissions-36e0</guid>
      <description>&lt;h2&gt;
  
  
  A disabled Submit button protects the UI. It does not protect the data.
&lt;/h2&gt;

&lt;p&gt;It is tempting to treat this as enough protection against duplicate submissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isSubmitting&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Submit
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The button is worth disabling. It gives the user immediate feedback and prevents an obvious second click. But it only controls one interaction in one browser tab.&lt;/p&gt;

&lt;p&gt;It cannot tell us whether a request was retried by the client, whether two requests crossed the network at nearly the same time, or whether the server completed a write before the response disappeared.&lt;/p&gt;

&lt;p&gt;Consider a small order form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;OrderFormValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;A straightforward submission might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&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;Now imagine that the server creates the order, but the response never reaches the browser. The UI sees a network error. The user tries again. Without any other protection, the second request can create a second order.&lt;/p&gt;

&lt;p&gt;The difficult part is not the second click. The difficult part is deciding whether the second request represents a new operation or a retry of the first one.&lt;/p&gt;

&lt;p&gt;The mental model I use separates two identities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;fingerprint&lt;/strong&gt; identifies the content of the request.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;idempotency key&lt;/strong&gt; identifies the operation the user intended to perform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They solve different problems, and neither is a replacement for the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content identity starts with normalization
&lt;/h2&gt;

&lt;p&gt;Two form values can be different in JavaScript while representing the same business input.&lt;/p&gt;

&lt;p&gt;These names are not byte-for-byte equal:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;But if the application trims names before saving them, that whitespace is not a meaningful change. The same issue appears with values such as &lt;code&gt;"150"&lt;/code&gt; and &lt;code&gt;150&lt;/code&gt;, or &lt;code&gt;150&lt;/code&gt; and &lt;code&gt;150.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I prefer to make those rules explicit before creating a fingerprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderFormValues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;priceInCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&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;function&lt;/span&gt; &lt;span class="nf"&gt;canonicalizeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderFormValues&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalizeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&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;Converting the price to cents is part of the example's business normalization. It avoids using small floating-point differences as evidence that two prices are different.&lt;/p&gt;

&lt;p&gt;For local comparisons, the canonical string is already a useful fingerprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canonicalizeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For storage or comparison across system boundaries, I would usually hash that canonical value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createOrderFingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderFormValues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canonicalizeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The normalization rules must come from the domain. If array order has no business meaning, sort the array before hashing it. If order is meaningful, sorting it would create the wrong identity. A fingerprint is only as correct as the canonical representation behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dirty state is also a domain question
&lt;/h2&gt;

&lt;p&gt;Form libraries usually expose an &lt;code&gt;isDirty&lt;/code&gt; flag. That flag is useful, but it normally compares raw form values with the original defaults. It does not know which differences matter to the business.&lt;/p&gt;

&lt;p&gt;A business-aware check can compare the initial and current fingerprints instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initialFingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canonicalizeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialValues&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentFingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canonicalizeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentValues&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isMeaningfullyDirty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;currentFingerprint&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;initialFingerprint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the user changes &lt;code&gt;"Sara"&lt;/code&gt; to &lt;code&gt;" Sara "&lt;/code&gt;, the raw form may be dirty while &lt;code&gt;isMeaningfullyDirty&lt;/code&gt; remains &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the user changes &lt;code&gt;amount&lt;/code&gt; from &lt;code&gt;2&lt;/code&gt; to &lt;code&gt;3&lt;/code&gt;, the fingerprint changes. If they change it back to &lt;code&gt;2&lt;/code&gt;, the fingerprints match again and the form is no longer meaningfully dirty.&lt;/p&gt;

&lt;p&gt;This is also why UI-only state should stay out of the fingerprint. A search query, an expanded panel, or the selected tab may change the component state without changing the order that will be sent to the server.&lt;/p&gt;

&lt;p&gt;For example, &lt;code&gt;customerSearch&lt;/code&gt; does not belong in the normalized payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;OrderPageState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;OrderFormValues&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;customerSearch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeOrderPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderPageState&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="nf"&gt;normalizeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&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;Using the same canonicalization rules for Dirty State and submission gives both features the same definition of a meaningful change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operation identity belongs to the idempotency key
&lt;/h2&gt;

&lt;p&gt;A fingerprint tells us that two payloads contain the same business data. It does not tell us whether the user intended one operation or two.&lt;/p&gt;

&lt;p&gt;A user may intentionally create two identical orders. Those orders have the same fingerprint, but they are still separate operations. This is why I would not derive the idempotency key from the fingerprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Incorrect: identical payloads can still be separate operations.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, the client creates a random key for a new operation and reuses it only when retrying that operation with the same fingerprint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PendingOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pendingOperation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PendingOperation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submitOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderFormValues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canonicalizeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;operation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;pendingOperation&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;pendingOperation&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nx"&gt;pendingOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&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="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Order submission failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// A later submission now represents a new operation,&lt;/span&gt;
  &lt;span class="c1"&gt;// even if the user enters the same values again.&lt;/span&gt;
  &lt;span class="nx"&gt;pendingOperation&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="nx"&gt;result&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;There are two deliberate choices in this code.&lt;/p&gt;

&lt;p&gt;First, a failed attempt does not immediately discard the key. Retrying the same payload should reuse the identity of the pending operation.&lt;/p&gt;

&lt;p&gt;Second, a successful response clears that identity. If the user intentionally submits the same values again later, the client generates a new key and the server is allowed to create another order.&lt;/p&gt;

&lt;p&gt;Disabling the button still makes sense, but the key is what allows the server to recognize a repeated operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server has to bind the key to the payload
&lt;/h2&gt;

&lt;p&gt;The client can help manage operation identity, but the guarantee must exist on the server.&lt;/p&gt;

&lt;p&gt;For each idempotency key, the server needs to store at least:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;IdempotencyRecord&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;succeeded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server should calculate the fingerprint itself. Trusting a fingerprint supplied by the browser would allow a modified client to claim that two different payloads are identical.&lt;/p&gt;

&lt;p&gt;The key also has to be claimed atomically. A separate &lt;code&gt;find()&lt;/code&gt; followed by &lt;code&gt;insert()&lt;/code&gt; leaves a race in which two requests can both observe that the key is missing.&lt;/p&gt;

&lt;p&gt;The storage API can expose that atomic decision directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ClaimResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;new&lt;/span&gt;&lt;span class="dl"&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;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mismatch&lt;/span&gt;&lt;span class="dl"&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;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processing&lt;/span&gt;&lt;span class="dl"&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;{&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Idempotency key is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;OrderFormValues&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createOrderFingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ClaimResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;idempotencyStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mismatch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Key reused with a different payload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;422&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Operation is already processing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;202&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;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;orderRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalizeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;idempotencyStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;201&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;In a relational database, a unique constraint on the key is the minimum protection against concurrent claims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idempotency_key_unique&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;idempotency_records&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact persistence model depends on the operation, but the invariant should remain the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same key and the same fingerprint refer to the same operation.&lt;/li&gt;
&lt;li&gt;The same key with a different fingerprint is rejected.&lt;/li&gt;
&lt;li&gt;A new key represents a new operation, even when its fingerprint matches an older one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The mistakes I would avoid
&lt;/h2&gt;

&lt;p&gt;The first mistake is treating &lt;code&gt;isSubmitting&lt;/code&gt; as a data-integrity mechanism. It is UI state, and it cannot protect the server from concurrent or repeated requests.&lt;/p&gt;

&lt;p&gt;The second is using a fingerprint as the idempotency key. That incorrectly merges separate operations that happen to have identical payloads.&lt;/p&gt;

&lt;p&gt;The third is hashing raw form state. Whitespace, numeric representation, property order, or UI-only fields can create different hashes for the same business input. Canonicalization has to happen before hashing.&lt;/p&gt;

&lt;p&gt;The final mistake is implementing the guarantee only in the browser. The browser can choose and reuse a key, but only durable, atomic server-side storage can enforce it.&lt;/p&gt;

&lt;p&gt;The compact version of the model is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Dirty State compares the current content with the initial content.&lt;br&gt;&lt;br&gt;
A fingerprint identifies that content.&lt;br&gt;&lt;br&gt;
An idempotency key identifies the operation performed with it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once those responsibilities are separate, the Submit button can remain what it should be: a useful interaction detail, not the last line of defense against duplicate writes.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>typescript</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why Your Zod Validation Fails Before It Even Runs (And How to Fix It)</title>
      <dc:creator>mohammad rostami</dc:creator>
      <pubDate>Thu, 11 Jun 2026 08:43:43 +0000</pubDate>
      <link>https://dev.to/mohammad_rostami_/why-your-zod-validation-fails-before-it-even-runs-and-how-to-fix-it-51d3</link>
      <guid>https://dev.to/mohammad_rostami_/why-your-zod-validation-fails-before-it-even-runs-and-how-to-fix-it-51d3</guid>
      <description>&lt;p&gt;If you're using Zod with react-hook-form, you've probably seen this at least once:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Invalid input: expected number, received NaN&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;At first glance, it looks like a simple validation issue.&lt;br&gt;
It’s not.&lt;/p&gt;
&lt;h3&gt;
  
  
  The real problem
&lt;/h3&gt;

&lt;p&gt;When working with form inputs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All values come in as &lt;strong&gt;strings&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;z.coerce.number()&lt;/code&gt; tries to convert them&lt;/li&gt;
&lt;li&gt;Empty input (&lt;code&gt;""&lt;/code&gt;) or invalid values → &lt;code&gt;NaN&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here’s the catch:&lt;br&gt;
Zod fails &lt;strong&gt;before&lt;/strong&gt; your &lt;code&gt;.min()&lt;/code&gt; / &lt;code&gt;.max()&lt;/code&gt; validations run.&lt;/p&gt;

&lt;p&gt;So instead of your custom message, you get a generic (and not very helpful) error.&lt;/p&gt;
&lt;h3&gt;
  
  
  It gets trickier
&lt;/h3&gt;

&lt;p&gt;If you're using TypeScript:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;z.input&amp;lt;typeof schema&amp;gt;&lt;/code&gt; ≠ &lt;code&gt;z.output&amp;lt;typeof schema&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;react-hook-form&lt;/code&gt; works with the &lt;strong&gt;input type&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Zod gives you the &lt;strong&gt;output type after parsing&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This mismatch can lead to confusing type errors and wrong assumptions about your data.&lt;/p&gt;
&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;You need to handle invalid input &lt;em&gt;before&lt;/em&gt; Zod tries to validate it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;readTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preprocess&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Minimum read time is 2 minutes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Maximum read time is 60 minutes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  What this solves
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Empty input → handled properly&lt;/li&gt;
&lt;li&gt;Invalid numbers → no more NaN issues&lt;/li&gt;
&lt;li&gt;Custom validation messages → actually shown&lt;/li&gt;
&lt;li&gt;Cleaner UX overall&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Takeaway
&lt;/h3&gt;

&lt;p&gt;If you're using &lt;code&gt;z.coerce.number()&lt;/code&gt; in forms, don't rely on it blindly.&lt;br&gt;
Always normalize your input first.&lt;/p&gt;

&lt;p&gt;Because sometimes the bug isn't in your validation rules…&lt;br&gt;
it's in how your data &lt;em&gt;arrives&lt;/em&gt; before validation even starts.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>zod</category>
      <category>nan</category>
      <category>react</category>
    </item>
    <item>
      <title>Technical Deep Dive: How React Server Components Work and Where the Vulnerabilities Appear</title>
      <dc:creator>mohammad rostami</dc:creator>
      <pubDate>Mon, 15 Dec 2025 20:42:52 +0000</pubDate>
      <link>https://dev.to/mohammad_rostami_/technical-deep-dive-how-react-server-components-work-and-where-the-vulnerabilities-appear-po6</link>
      <guid>https://dev.to/mohammad_rostami_/technical-deep-dive-how-react-server-components-work-and-where-the-vulnerabilities-appear-po6</guid>
      <description>&lt;p&gt;Hi everyone&lt;br&gt;
In a recent, I examined CVE-2025-55182, a critical vulnerability in React Server Components caused by an unsafe deserialization path that could lead to unauthenticated remote code execution. That issue highlighted how deeply framework internals can influence the security posture of applications built on top of them.&lt;/p&gt;

&lt;p&gt;Shortly after that disclosure, an additional set of security vulnerabilities was published affecting the same architectural layer in React Server Components (RSC) and Next.js. While these issues do not result in immediate remote code execution, they introduce significant risks, including Denial of Service (DoS) and unintended exposure of server-side source code and internal implementation details under specific conditions.&lt;/p&gt;

&lt;p&gt;This article builds on that context and focuses on the underlying execution model of React Server Components, explaining how these issues emerge at the framework level and why they matter for production deployments.&lt;/p&gt;

&lt;p&gt;To understand why the recently disclosed issues in React Server Components (RSC) lead to Denial of Service and source code exposure, it helps to look at how the RSC execution pipeline actually works on the server.&lt;/p&gt;
&lt;h2&gt;
  
  
  React Server Components: Server-Side Execution Flow
&lt;/h2&gt;

&lt;p&gt;A simplified but accurate RSC request lifecycle looks like this:&lt;/p&gt;
&lt;h2&gt;
  
  
  1 - Incoming request reaches the RSC endpoint
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;In Next.js, this typically maps to an internal route such as /_rsc&lt;/li&gt;
&lt;li&gt;The request is processed before authentication and before application middleware&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  2 - Request body parsing and protocol decoding
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The payload is parsed according to the React Flight protocol&lt;/li&gt;
&lt;li&gt;This includes decoding serialized component references, props, and execution hints&lt;/li&gt;
&lt;li&gt;At this stage, React assumes the payload shape is valid enough to be processed&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  3 - Component graph reconstruction
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;React reconstructs the Server Component tree from the serialized input&lt;/li&gt;
&lt;li&gt;Module references are resolved and loaded&lt;/li&gt;
&lt;li&gt;Async boundaries and Suspense segments are registered&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  4 - Server rendering and streaming
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Components are executed on the server&lt;/li&gt;
&lt;li&gt;Output is serialized again and streamed back to the client in chunks&lt;/li&gt;
&lt;li&gt;Error boundaries and partial results are flushed incrementally&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  5 - Error and recovery
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If an exception occurs mid-stream, React attempts to recover gracefully&lt;/li&gt;
&lt;li&gt;Metadata, stack traces, and module identifiers are still available internally&lt;/li&gt;
&lt;li&gt;Each of these phases is sensitive to malformed or adversarial input.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  How Denial of Service Is Triggered
&lt;/h2&gt;

&lt;p&gt;The DoS issue arises primarily in steps 2 and 3, before any application logic is involved.&lt;br&gt;
A malicious request can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inflate the serialized payload size&lt;/li&gt;
&lt;li&gt;Introduce deeply nested or cyclic references&lt;/li&gt;
&lt;li&gt;Force excessive async boundary resolution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conceptual example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /_rsc
Content-Type: application/json

{
  "type": "ServerComponent",
  "props": {
    "children": {
      "children": {
        "children": {
          ...
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even if the payload is syntactically valid, React will:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Attempt to deserialize it&lt;/li&gt;
&lt;li&gt;Build an in-memory component graph&lt;/li&gt;
&lt;li&gt;Schedule execution and streaming work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This leads to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;High CPU usage during decoding&lt;/li&gt;
&lt;li&gt;Memory pressure during graph reconstruction&lt;/li&gt;
&lt;li&gt;Worker or event-loop saturation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Crucially, this happens without hitting user-defined code and without authentication.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  This makes traditional defensive assumptions at the application layer largely ineffective.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  How Source Code Exposure Can Occur
&lt;/h2&gt;

&lt;p&gt;Source code exposure is a side effect of how React handles errors during steps 4 and 5.&lt;br&gt;
When a rendering or serialization error occurs mid-stream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React still holds references to internal module paths&lt;/li&gt;
&lt;li&gt;Stack traces include resolved file locations&lt;/li&gt;
&lt;li&gt;Component metadata is partially serialized&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In certain edge cases, this information may:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Leak into the streamed response&lt;/li&gt;
&lt;li&gt;Appear in error payloads&lt;/li&gt;
&lt;li&gt;Be flushed before the stream is properly aborted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example of leaked information:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Failed to resolve Server Component
  at /app/src/components/admin/UserList.server.tsx
  at react-server-dom-webpack/server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reveals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Project directory structure&lt;/li&gt;
&lt;li&gt;Server-only component boundaries&lt;/li&gt;
&lt;li&gt;Internal module layout and naming&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  While not a standalone exploit, this materially reduces the effort required for targeted attacks.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why Server Actions Are Not Required
&lt;/h2&gt;

&lt;p&gt;A common misconception is that these risks only apply when using Server Actions.&lt;br&gt;
In reality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server Actions are an API surface&lt;/li&gt;
&lt;li&gt;RSC is a runtime and protocol&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even a simple page like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;export default async function Page() {
  return &amp;lt;Dashboard /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still triggers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request deserialization&lt;/li&gt;
&lt;li&gt;Component graph reconstruction&lt;/li&gt;
&lt;li&gt;Server execution and streaming&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The vulnerable logic executes regardless of whether Server Actions are defined.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;React Server Components introduce a fundamentally new server-side execution model that combines request deserialization, component graph reconstruction, and streaming-based rendering into a single framework-managed pipeline.&lt;/li&gt;
&lt;li&gt;As a result, the effective attack surface exists before authentication, before application middleware, and before any user-defined business logic is executed.&lt;/li&gt;
&lt;li&gt;Malformed or adversarial RSC requests can lead to:&lt;/li&gt;
&lt;li&gt;Denial of Service, through excessive CPU usage, memory pressure, and worker or event-loop saturation during deserialization and component graph reconstruction&lt;/li&gt;
&lt;li&gt;Accidental disclosure of server-side implementation details, including internal file paths, component boundaries, and module layout, as a side effect of streaming error handling&lt;/li&gt;
&lt;li&gt;These issues are not caused by misconfigured applications or unsafe user code. They are framework-level vulnerabilities in the React Server Components runtime itself and cannot be mitigated reliably through application-layer defenses alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Official advisories and patches&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components" rel="noopener noreferrer"&gt;Denial of Service and Source Code Exposure in React Server Components&lt;/a&gt;
&lt;/h3&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://nextjs.org/blog/security-update-2025-12-11" rel="noopener noreferrer"&gt;Next.js security update&lt;/a&gt;
&lt;/h3&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>security</category>
      <category>servercomponent</category>
    </item>
    <item>
      <title>A Deep Dive into Nested ScrollView Behavior in React Native: Root Causes and Practical Solutions</title>
      <dc:creator>mohammad rostami</dc:creator>
      <pubDate>Mon, 24 Nov 2025 21:24:08 +0000</pubDate>
      <link>https://dev.to/mohammad_rostami_/a-deep-dive-into-nested-scrollview-behavior-in-react-native-root-causes-and-practical-solutions-bmk</link>
      <guid>https://dev.to/mohammad_rostami_/a-deep-dive-into-nested-scrollview-behavior-in-react-native-root-causes-and-practical-solutions-bmk</guid>
      <description>&lt;h2&gt;
  
  
  Hi everyone
&lt;/h2&gt;

&lt;p&gt;During the development of a recent screen in one of our applications, I faced a scenario where a section of the content needed to scroll independently, while the entire page itself was wrapped inside a parent ScrollView. At first glance, this seems like a straightforward layout. In practice, however, the behavior of nested scroll containers became unexpectedly challenging, especially when tested across platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observed Behavior Across Platforms
&lt;/h2&gt;

&lt;p&gt;On iOS, the internal ScrollView generally behaves as expected without requiring additional configuration.&lt;/p&gt;

&lt;p&gt;On Android, the situation is different. The inner scroll often fails to activate, gets interrupted by the outer scroll, or reacts inconsistently. This occurred not only on lower-end devices but on high-end models as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  The issue becomes even more noticeable when:
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The two scroll layers have different directions (for example, vertical parent and horizontal child)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The inner content has dynamic height or width&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Multiple simultaneous gestures are triggered during rapid scrolling&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It quickly became clear that this was not simply a styling conflict, but a deeper issue related to gesture priority and event handling at the platform level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root Cause Analysis: What Actually Happens Behind the Scenes
&lt;/h2&gt;

&lt;p&gt;After reviewing React Native’s gesture responder system and native scroll behavior in both iOS and Android, several key points emerged:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;On Android, the parent ScrollView receives gesture priority by default &lt;br&gt;
It attempts to determine whether the gesture belongs to itself before allowing the child ScrollView to respond. This decision is not always accurate, which often prevents the inner scroll from initiating.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On iOS, gesture separation is more precise&lt;br&gt;
The system determines the appropriate scroll target early and allows both parent and child scroll views to operate smoothly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Mixed-direction scrolling increases complexity&lt;br&gt;
When the parent scrolls vertically and the child scrolls horizontally, the system must re-evaluate gesture direction multiple times. On Android, this frequently results in jumps, unexpected direction changes, or the parent taking control prematurely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dynamic layout measurement affects gesture routing&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Incorrect or late size calculations can cause the inner ScrollView to lose gesture ownership too early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Solutions Based on Testing
&lt;/h2&gt;

&lt;p&gt;Several solutions helped achieve consistent, reliable behavior for nested scrolling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable nestedScrollEnabled on the inner ScrollView (Android)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;ScrollView&amp;gt;

     &amp;lt;ScrollView nestedScrollEnabled&amp;gt;

        {/* content */}

     &amp;lt;/ScrollView&amp;gt;

&amp;lt;/ScrollView&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the foundational setting required to ensure the child scroll can properly receive gestures.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use flexGrow for proper content measurement
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;contentContainerStyle={{ flexGrow: 1 }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures the inner ScrollView’s content is measured correctly, reducing gesture conflicts with the parent view.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Handle mixed-direction scrolling intentionally&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the parent and child scroll in different directions, it is often beneficial to temporarily enable or disable one of the scroll containers based on the detected gesture direction.&lt;/p&gt;

&lt;p&gt;This approach helps the system correctly determine which layer should receive the gesture and prevents unintended handoffs during scroll.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complete Example
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// https://www.linkedin.com/in/mohammadrostami/
import React from 'react';
import { ScrollView, View, Text, StyleSheet } from 'react-native';

export default function NestedScrollExample() {
  return (
    &amp;lt;ScrollView style={styles.parentScroll}&amp;gt;
      &amp;lt;View style={styles.container}&amp;gt;

        &amp;lt;Text style={styles.title}&amp;gt;Parent ScrollView&amp;lt;/Text&amp;gt;

        &amp;lt;ScrollView
          nestedScrollEnabled
          style={styles.childScroll}
          contentContainerStyle={{ flexGrow: 1 }}
        &amp;gt;
          &amp;lt;View style={styles.childContent}&amp;gt;
            {Array.from({ length: 25 }).map((_, i) =&amp;gt; (
              &amp;lt;Text style={styles.item} key={i}&amp;gt;
                Child Item {i + 1}
              &amp;lt;/Text&amp;gt;
            ))}
          &amp;lt;/View&amp;gt;
        &amp;lt;/ScrollView&amp;gt;

        &amp;lt;View style={styles.footer}&amp;gt;
          &amp;lt;Text style={styles.footerText}&amp;gt;Footer Content&amp;lt;/Text&amp;gt;
        &amp;lt;/View&amp;gt;

      &amp;lt;/View&amp;gt;
    &amp;lt;/ScrollView&amp;gt;
  );
}

const styles = StyleSheet.create({
  parentScroll: {
    flex: 1,
    backgroundColor: '#fafafa',
  },
  container: {
    padding: 16,
  },
  title: {
    fontSize: 18,
    marginBottom: 12,
  },
  childScroll: {
    height: 250,
    borderRadius: 8,
    borderWidth: 1,
    borderColor: '#ccc',
  },
  childContent: {
    padding: 12,
  },
  item: {
    paddingVertical: 10,
    fontSize: 16,
  },
  footer: {
    marginTop: 30,
    padding: 12,
    backgroundColor: '#eee',
    borderRadius: 8,
  },
  footerText: {
    fontSize: 16,
  },
});

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Nested ScrollViews introduce a set of platform-specific challenges in React Native, particularly in Android where gesture routing differs significantly from iOS. Understanding how gestures are prioritized, how mixed-direction scrolling is handled, and how layout measurement affects scroll behavior is essential for creating smooth, predictable user experiences.&lt;br&gt;
Properly configuring nestedScrollEnabled, managing dynamic content sizing, and intentionally controlling scroll interactions are key to resolving these issues in production-grade applications.&lt;/p&gt;

</description>
      <category>android</category>
      <category>ui</category>
      <category>ios</category>
      <category>reactnative</category>
    </item>
  </channel>
</rss>
