<?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: Danila Pryadko</title>
    <description>The latest articles on DEV Community by Danila Pryadko (@danila_pryadko_3561959a14).</description>
    <link>https://dev.to/danila_pryadko_3561959a14</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%2F4069295%2F0f4a7aa1-a542-4dc4-a6ae-4152d81ca73d.png</url>
      <title>DEV Community: Danila Pryadko</title>
      <link>https://dev.to/danila_pryadko_3561959a14</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danila_pryadko_3561959a14"/>
    <language>en</language>
    <item>
      <title>How I restored Chrome tab groups without scrambling tab order</title>
      <dc:creator>Danila Pryadko</dc:creator>
      <pubDate>Sun, 09 Aug 2026 17:53:50 +0000</pubDate>
      <link>https://dev.to/danila_pryadko_3561959a14/how-i-restored-chrome-tab-groups-without-scrambling-tab-order-1jm3</link>
      <guid>https://dev.to/danila_pryadko_3561959a14/how-i-restored-chrome-tab-groups-without-scrambling-tab-order-1jm3</guid>
      <description>&lt;p&gt;&lt;em&gt;A two-pass restore algorithm that preserves global tab order, duplicate URLs, pinned tabs, group metadata, and the active tab — and reports partial failures.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I build Tabwell, a local-first Chrome session manager.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My first restore implementation passed the only assertion I had: every saved URL reopened. Then I used it on a mixed grouped-and-ungrouped session that the test did not cover.&lt;/p&gt;

&lt;p&gt;I'd saved a window with a pinned Gmail tab, a research group, a build group, and a few loose tabs in between. Hit restore. Every URL opened. And the window was still wrong — the loose tabs were bunched at the end instead of sitting between the groups, the duplicate URLs could no longer be distinguished during regrouping, and the intended active tab was lost. Nothing crashed. No error anywhere. The session was just... not my session anymore. That's the moment this post is about: a browser session can look successfully restored and still be damaged.&lt;/p&gt;

&lt;p&gt;Here's a layout that breaks naive implementations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[pinned tab]
→ [Research: A, B]
→ [ungrouped C]
→ [Build: D, D]
→ [ungrouped E]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;D&lt;/code&gt; tabs intentionally contain the same URL.&lt;/p&gt;

&lt;p&gt;The tempting way to restore this is one group at a time, then the ungrouped tabs at the end. All URLs reopen, so a simple "did everything open" test passes. But the tab strip no longer matches the original global order — &lt;code&gt;C&lt;/code&gt; is no longer between the groups, and, if URL is used as the mapping key, the two &lt;code&gt;D&lt;/code&gt;s collapse into one mapping entry.&lt;/p&gt;

&lt;p&gt;While building restore for Tabwell, I eventually stopped thinking of it as "reopen a list of URLs" and started treating it as reconstruction of a small object graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does "restored" actually mean?
&lt;/h2&gt;

&lt;p&gt;Before rewriting the algorithm I had to pin down the contract, because "it works" was clearly not a spec.&lt;/p&gt;

&lt;p&gt;For one saved Chrome window, a successful restore should preserve:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the global order of tabs;&lt;/li&gt;
&lt;li&gt;separate instances of duplicate URLs;&lt;/li&gt;
&lt;li&gt;pinned state;&lt;/li&gt;
&lt;li&gt;the intended active tab;&lt;/li&gt;
&lt;li&gt;group membership;&lt;/li&gt;
&lt;li&gt;each group's title, color, and collapsed state;&lt;/li&gt;
&lt;li&gt;the original window state, where Chrome allows it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It should also report partial success honestly. If nine of ten tabs open, a generic "success" is a lie. But throwing the whole result away isn't obviously better either — a window with nine perfectly good tabs already exists on the user's screen.&lt;/p&gt;

&lt;p&gt;And some things a URL-based snapshot simply cannot bring back: text typed into forms, scroll position, unsaved editor state, application state hiding behind an unchanged URL, anything that happened after the snapshot. I treat that boundary as a product constraint, not just a code detail, because users will otherwise assume more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime IDs are references, not identities
&lt;/h2&gt;

&lt;p&gt;Chrome gives every tab and tab group a numeric ID. Those IDs identify objects in the &lt;em&gt;current&lt;/em&gt; browser runtime — a freshly restored window gets entirely new ones.&lt;/p&gt;

&lt;p&gt;A simplified snapshot model:&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;SavedGroup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;savedRef&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;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;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ColorEnum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;collapsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SavedTab&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;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;url&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;index&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;pinned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;active&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;savedGroupRef&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;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SavedWindow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;windowState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;windows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;windowStateEnum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SavedGroup&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SavedTab&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;savedGroupRef&lt;/code&gt; is a pedagogical name — in Tabwell's actual stored schema, the captured numeric group ID serves as the relation between a saved tab and its saved group. The name doesn't matter; the semantics do. That number means something &lt;em&gt;inside the snapshot only&lt;/em&gt;. It is never treated as the ID of the future Chrome group.&lt;/p&gt;

&lt;p&gt;During restoration, &lt;code&gt;chrome.tabs.group()&lt;/code&gt; hands back a brand-new group ID, and that new ID is what goes into &lt;code&gt;chrome.tabGroups.update()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmo72nb1vijwnkeznznbc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmo72nb1vijwnkeznznbc.png" alt="Expanded Tabwell snapshot showing three named groups and their saved tabs; the Inbox group contains two mail.google.com tabs" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The duplicate Gmail URLs still have separate saved tab IDs.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the restore needs two passes
&lt;/h2&gt;

&lt;p&gt;The algorithm that finally held up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Materialize every saved tab in global order. Tabwell reuses the new window's default blank tab for the first successfully restored item, then creates the remaining tabs.&lt;/li&gt;
&lt;li&gt;Record the mapping from saved tab IDs to new Chrome tab IDs.&lt;/li&gt;
&lt;li&gt;Build the groups from those new tab IDs.&lt;/li&gt;
&lt;li&gt;Apply group metadata.&lt;/li&gt;
&lt;li&gt;Restore the intended active tab last.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  Pass one: create tabs
&lt;/h3&gt;

&lt;p&gt;Sort the selected tabs by their captured index, falling back to snapshot order. A newly created Chrome window already contains one blank tab, so the production path reuses it for the first successfully restored tab. The helper in this simplified loop hides that bookkeeping and returns either the new runtime tab ID or &lt;code&gt;undefined&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orderedTabs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;savedWindow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compareCapturedOrder&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;newTabIdBySavedTabId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&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;failedUrls&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;savedTab&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;orderedTabs&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;newTabId&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;createRestoredTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;savedTab&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;windowId&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;newTabId&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;failedUrls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;savedTab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;newTabIdBySavedTabId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;savedTab&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="nx"&gt;newTabId&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 map is keyed by the saved tab ID, not the URL. Key by URL and two saved entries collapse into one mapping entry, so a later step can no longer assign group membership and active state to the correct tab. Two tabs with the same URL are still two distinct browser objects.&lt;/p&gt;

&lt;p&gt;Creating everything in global order also dodges a subtler grouping problem: Chrome groups are contiguous. If you restore group A, then group B, then the ungrouped tabs, you have already destroyed any layout where grouped and ungrouped tabs were interleaved. Fixing that afterwards requires a separate tab-moving pass, so it is simpler not to destroy the order in the first place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pass two: create groups
&lt;/h3&gt;

&lt;p&gt;Once the tabs exist, each saved group is rebuilt from the tab IDs that were actually created:&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;let&lt;/span&gt; &lt;span class="nx"&gt;failedGroups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;savedGroup&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;savedWindow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groups&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;newTabIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;orderedTabs&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;savedGroupRef&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;savedGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;savedRef&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tab&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;newTabIdBySavedTabId&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="nx"&gt;tab&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&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="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;id&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newTabIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newGroupId&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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;tabIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newTabIds&lt;/span&gt; &lt;span class="k"&gt;as&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;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]],&lt;/span&gt;
      &lt;span class="na"&gt;createProperties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;windowId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newGroupId&lt;/span&gt;&lt;span class="p"&gt;,&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="nx"&gt;savedGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;savedGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;collapsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;savedGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;collapsed&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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;failedGroups&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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;After grouping settles, the saved active tab gets reactivated through the saved-ID-to-new-ID map. Grouping can change which tab Chrome leaves active, so the intended tab is activated only after all group operations finish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partial restore is a real result
&lt;/h2&gt;

&lt;p&gt;Once a new window exists, restoration stops being an all-or-nothing transaction, whether you like it or not.&lt;/p&gt;

&lt;p&gt;A tab can fail when Chrome rejects the create or update call, or when a create call returns no tab ID. A later page-load failure is outside this restore result. A group operation can fail &lt;em&gt;after&lt;/em&gt; its tabs already opened. Instead of hiding all that behind one generic exception, the restore path returns structure:&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;RestoreResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;windowId&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;requested&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;restored&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;failed&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;failedUrls&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;failedGroups&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;Now the UI can say "9 of 10 tabs restored" and point at the URL that failed.&lt;/p&gt;

&lt;p&gt;It also makes a dangerous retry pattern visible. If the caller assumes the whole operation failed and just runs it again, the second attempt duplicates every tab that had already opened. The caller can instead decide whether to keep, remove, or explicitly retry the partial window.&lt;/p&gt;

&lt;p&gt;URL handling needs its own boundary, and it isn't one-size-fits-all. For imported third-party session files, a strict &lt;code&gt;http:&lt;/code&gt;/&lt;code&gt;https:&lt;/code&gt; allowlist is reasonable. For snapshots captured from the user's own browser, Tabwell does not pre-filter &lt;code&gt;chrome:&lt;/code&gt; or &lt;code&gt;file:&lt;/code&gt; URLs, although Chrome may refuse or rewrite them at restore time. It still rejects dangerous schemes such as &lt;code&gt;javascript:&lt;/code&gt;, &lt;code&gt;data:&lt;/code&gt;, and &lt;code&gt;vbscript:&lt;/code&gt; before any create or update call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manifest V3 changes the persistence assumptions
&lt;/h2&gt;

&lt;p&gt;An MV3 service worker is not a permanent process. Anything held only in memory can be gone between two events. Tabwell therefore treats persistence as the save boundary: a save counts as successful only after the snapshot reaches local IndexedDB.&lt;/p&gt;

&lt;p&gt;The normal restore flow is not a resumable transaction journal. After partial success, it reports what happened instead of retrying automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test invariants, not just the happy path
&lt;/h2&gt;

&lt;p&gt;"Three URLs reopened" is not an interesting test. The interesting ones encode the invariants:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;grouped and ungrouped tabs interleaved in one window;&lt;/li&gt;
&lt;li&gt;two tabs with exactly the same URL;&lt;/li&gt;
&lt;li&gt;a pinned ungrouped tab;&lt;/li&gt;
&lt;li&gt;the intended active tab restored after grouping;&lt;/li&gt;
&lt;li&gt;a separate group whose collapsed state must survive;&lt;/li&gt;
&lt;li&gt;groups with different titles and colors;&lt;/li&gt;
&lt;li&gt;restoring only one selected group;&lt;/li&gt;
&lt;li&gt;one URL rejected while the rest succeed;&lt;/li&gt;
&lt;li&gt;a group operation failing after its tabs were created;&lt;/li&gt;
&lt;li&gt;an empty selection, or one with no matching tabs, rejected before any window is created.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the assertions have to inspect the real restored window — tab order, group membership, metadata, pinned state, active tab. Counting opened URLs is precisely the test that passed while my first implementation was scrambling everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three rules I'm keeping
&lt;/h2&gt;

&lt;p&gt;The implementation now follows three constraints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Runtime IDs can serve as references inside a snapshot, but they are not identities for future runtime objects.&lt;/li&gt;
&lt;li&gt;Rebuild global ordering before rebuilding the relationships that can move those objects around.&lt;/li&gt;
&lt;li&gt;Once an operation has irreversible partial success, return a structured result — don't flatten it into an exception and a suggestion to blindly retry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I implemented this restore path in Tabwell.&lt;/p&gt;

&lt;p&gt;A question for you: once a manual restore window exists and one tab fails, what would you actually prefer — keep the partial window, roll it back, or build a resumable restore journal? For ordinary manual restore I keep the partial window and report it; automatic crash recovery uses a separate rollback path.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I used AI to help structure an early outline and check the final draft against the implementation. I rewrote the article from my own implementation experience and verified the technical claims against the code.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>typescript</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
    <item>
      <title>One Checkbox, Three Kinds of State in a Chrome MV3 Extension</title>
      <dc:creator>Danila Pryadko</dc:creator>
      <pubDate>Sat, 08 Aug 2026 23:38:17 +0000</pubDate>
      <link>https://dev.to/danila_pryadko_3561959a14/one-checkbox-three-kinds-of-state-in-a-chrome-mv3-extension-3cj9</link>
      <guid>https://dev.to/danila_pryadko_3561959a14/one-checkbox-three-kinds-of-state-in-a-chrome-mv3-extension-3cj9</guid>
      <description>&lt;p&gt;I thought I had a settings bug. What I actually had was three different kinds of state pretending to be one boolean.&lt;/p&gt;

&lt;p&gt;While building a Chrome Manifest V3 email-tracker blocker, I expected a simple flow: you flip Gmail on in the settings, and the extension starts working in Gmail. That was the theory, anyway.&lt;/p&gt;

&lt;p&gt;The problem showed up when I was testing on a second Chrome profile. I'd enabled Gmail on my main profile, and Chrome Sync helpfully carried that preference over to the other one. But the optional permission for &lt;code&gt;mail.google.com&lt;/code&gt; didn't come along — host grants live in the local profile and never sync. Profile number two now believed Gmail was enabled while lacking the host grant needed to inject the inbox content script or inspect its DOM. Depending on how you write your code, that's either a silent no-op or an extension quietly behaving as if access exists when it does not. Neither is great.&lt;/p&gt;

&lt;p&gt;Once I stopped and wrote it down, the picture got clearer. There are three separate things here: the inbox the user &lt;em&gt;wants&lt;/em&gt; enabled, the host access Chrome has &lt;em&gt;actually granted&lt;/em&gt; in this profile, and the dynamic DNR rules that are &lt;em&gt;currently installed&lt;/em&gt;. Collapsing them into one flag is convenient. It's also wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manifest is a menu, not an order
&lt;/h2&gt;

&lt;p&gt;The extension declares each webmail origin under &lt;code&gt;optional_host_permissions&lt;/code&gt;. Every inbox gets activated on its own, and Chrome only asks the user for access when they turn that particular integration on.&lt;/p&gt;

&lt;p&gt;Here's the thing I had to internalize: declaring an optional origin means nothing by itself. Until the live grant exists, the extension has no business registering a content script for that inbox, poking at its DOM, or — by its own scoping policy — activating client-scoped blocking rules for it.&lt;/p&gt;

&lt;p&gt;Why bother with per-inbox prompts at all? Mostly trust. A tracker blocker that asks for all your webmail up front looks exactly like the thing it's supposed to protect you from. Asking for Gmail when you enable Gmail — and nothing more — is an easy story to tell users, and it doesn't hurt during store review either. Chrome's docs recommend optional permissions for exactly this kind of informed control. The docs don't mention the hard part, though: making the runtime actually &lt;em&gt;preserve&lt;/em&gt; that choice through permission revocations, browser sync, and service worker restarts. That's on you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaged rules as templates
&lt;/h2&gt;

&lt;p&gt;The extension ships two packaged DNR rulesets, and both stay disabled. The background service worker loads the high-confidence pixel rules from &lt;code&gt;rules/pixels.json&lt;/code&gt; as a read-only template and clones only those into extension-owned, client-scoped dynamic rules. Recognized tracking links are handled by the content script rather than navigation DNR.&lt;/p&gt;

&lt;p&gt;The scope I want is, conceptually:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;clients enabled in settings  AND  hosts granted in this profile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Before generating anything, the extension asks &lt;code&gt;chrome.permissions.contains()&lt;/code&gt; about the origin. A synced setting is intent. It is not evidence of a current host grant, so the extension does not activate that client.&lt;/p&gt;

&lt;p&gt;The generated blocking rules list only the enabled-and-granted webmail origins in &lt;code&gt;initiatorDomains&lt;/code&gt;. If Gmail is the only authorized inbox, a request elsewhere on the web doesn't get blocked just because it happens to match a tracker pattern. The same transformation also emits higher-priority allow rules for tracker domains the user has explicitly whitelisted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuild everything, don't patch
&lt;/h2&gt;

&lt;p&gt;My first instinct was incremental: checkbox on, add rules; checkbox off, remove them. It works right up until reality intervenes. A permission gets revoked from Chrome's own extension settings page. Settings arrive via sync. The service worker dies between two state changes. Several permission and settings events land on top of each other. An update leaves stale dynamic rules behind from the previous version. I hit most of these within a week of testing.&lt;/p&gt;

&lt;p&gt;So the extension doesn't track transitions anymore. On every relevant event it rebuilds the entire desired rule set from current state: disable the packaged static rulesets, read the extension-owned dynamic rules, check the current optional host grants, treat anything ungranted as disabled, build the full desired set of block and allow rules, compare normalized fingerprints, and replace the owned rules only if something actually changed.&lt;/p&gt;

&lt;p&gt;Run it twice with the same inputs and you get the same rules and no write. That idempotence is what makes the whole thing debuggable. Reserved rule-ID ranges keep this component from stepping on rules it doesn't own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failing closed
&lt;/h2&gt;

&lt;p&gt;When something goes wrong, the extension errs toward doing less.&lt;/p&gt;

&lt;p&gt;If the permissions API is unavailable, every client is treated as inactive. If an individual permission check throws, that client is treated as inactive. If the packaged template can't be loaded while old extension-owned rules are still installed, those rules get removed before the error is reported.&lt;/p&gt;

&lt;p&gt;Yes, that can temporarily reduce protection. I went back and forth on this while testing — an argument can be made for keeping the last known-good rules around. But keeping behavior alive outside the scope the user explicitly granted felt worse than a gap. A tracker blocker that blocks things beyond that scope is a different kind of broken, and a scarier one.&lt;/p&gt;

&lt;h2&gt;
  
  
  One reconciliation at a time
&lt;/h2&gt;

&lt;p&gt;Settings and permissions can change nearly simultaneously. A user enables an inbox, answers the Chrome prompt, edits the allow list, then disables the inbox again — all inside a few seconds. If each event kicks off its own independent reconciliation, an older snapshot can finish &lt;em&gt;after&lt;/em&gt; a newer one and win.&lt;/p&gt;

&lt;p&gt;So reconciliations go through a queue. Each snapshot waits for the previous operation to settle. A failed operation doesn't poison the queue or stop later state from applying.&lt;/p&gt;

&lt;p&gt;Small detail. But in an MV3 service worker — event-driven, no permanent process — it's the difference between "eventually correct" and "correct until you click fast."&lt;/p&gt;

&lt;h2&gt;
  
  
  "Detected" is not "blocked"
&lt;/h2&gt;

&lt;p&gt;One more boundary worth writing down: production extensions don't get a tidy callback for every DNR match. The inbox UI detects tracker resources present in the message DOM, while DNR separately blocks matching image and ping requests. Two different signals, two different mechanisms.&lt;/p&gt;

&lt;p&gt;So a detection shown in the UI must not be described as proof that a specific network request was blocked. The product copy and reports keep "detected" and "blocked" as separate words on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the data lives
&lt;/h2&gt;

&lt;p&gt;Detection history and sender statistics sit in local IndexedDB. Attribution uses sender metadata, opaque identifiers from the webmail provider, and the relevant image or link URLs. Subject and body text aren't extracted, stored, or transmitted — with one honest asterisk: on Outlook, Superhuman, and Yahoo, the locally parsed accessibility label that carries the sender can also contain a subject line. I'd rather document that than pretend the wording is simpler than it is.&lt;/p&gt;

&lt;p&gt;Preferences may sync through Chrome; host grants stay local, as established. Paid operations and opt-in crash reporting are separate network paths, not part of inbox detection.&lt;/p&gt;

&lt;p&gt;Proton Mail deserves its own caveat too. Its image proxy is on by default, so browser-level DNR pixel blocking is constrained there. Sender attribution and tracking-link protection can still work, but I don't claim identical pixel coverage across clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests that earned their keep
&lt;/h2&gt;

&lt;p&gt;The useful test matrix turned out to be all about state transitions: a fresh install produces no client-scoped rules; enabling Gmail creates rules only once its host grant exists; a synced Gmail preference without the local grant creates nothing; revoking a grant removes that client's initiator domain; disabling one client leaves the others intact; an allow-listed tracker domain gets its higher-priority allow rule; a failed permission check disables the affected client; a template-loading failure clears stale owned rules; repeated reconciliation with unchanged state performs no update; and rapid snapshots apply in order.&lt;/p&gt;

&lt;p&gt;That last one surprised me most. Before the queue existed, I could reliably make the &lt;em&gt;older&lt;/em&gt; state win just by toggling a checkbox twice quickly — the first reconciliation, carrying stale state, finished second and overwrote the fresh one. Watching a disabled inbox come back to life because I clicked fast was the moment the queue stopped being optional.&lt;/p&gt;

&lt;p&gt;The lesson I keep coming back to: product settings, browser authority, and runtime rules are different kinds of state. Once I stopped mashing them into one flag, the permission model became something I could explain, test, and audit without hand-waving.&lt;/p&gt;

&lt;p&gt;How do other extension teams handle this? Do you rebuild the full desired dynamic-rule set from scratch, or apply incremental mutations across permission and settings events? I'd like to hear where incremental has actually held up.&lt;/p&gt;

&lt;p&gt;Implementation references:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.chrome.com/docs/extensions/reference/api/permissions" rel="noopener noreferrer"&gt;Chrome permissions API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest" rel="noopener noreferrer"&gt;Chrome Declarative Net Request API&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I used AI to help structure an early research outline. I rewrote this article from my own implementation experience and verified the technical claims against the implementation and Chrome's documentation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>architecture</category>
      <category>privacy</category>
      <category>chromeextension</category>
    </item>
  </channel>
</rss>
