Tab Canopy: what it is and why it exists
Your tabs don’t think in a straight line. You open a main article, then sources, then references from those. Tab Canopy organises them in a tree instead of a flat list. Nest tabs under others, drag to reorder, search with Ctrl+F. It lives in the side panel, works across windows, and stays in sync. Everything is stored locally in your browser.

It’s on the Chrome Web Store and Firefox Add-ons, and open source (MIT) on GitHub. WXT made it straightforward to build one codebase for both. Plenty of extensions already do tree-style tabs; I built this one to have a real use case for the Firtoz collection packages (IndexedDB + TanStack collections). The experiment turned into something I use every day.
Then the cracks showed. Moving a parent tab in the browser could scramble the tree; closing one could leave orphans; an update would sometimes overwrite the structure the UI had just set. Fixing one bug broke another. So I fixed the collections layer, rewired the extension around a single reconciler, and got the tests green. Here’s what changed and why it matters.
Why the big rewrite: bugs that wouldn’t go away
The bugs were the kind that don’t show up in demos. In real use: wrong order after moving a parent tab, inconsistent tree after closing one, and, the sneakiest one, browser update events overwriting the tree shape the UI had just written. Fix one thing, another regressed. Tab events and DB writes were scattered across many handlers, with races and no clear ownership of who writes what.
Two things had to change.
1. Collections layer
The extension sits on @firtoz/drizzle-indexeddb and related packages. The bugs exposed gaps in syncing and multi-client access. In a Chrome extension the side panel runs in a different context from the background; it can’t open IndexedDB in the same way. The old approach was an IDB proxy: the background held the real DB and the panel talked to it through a proxy client/server and sync adapter, so both sides were effectively sharing one DB over the messaging layer. That layer was removed from the package in favour of a simpler model: native IndexedDB only where it’s available (the background), and in contexts that can’t use it (the panel), a memory collection that receives explicit SyncMessage[] over your own transport. I bumped to ^1.0.0 for drizzle-indexeddb and drizzle-utils, added @firtoz/db-helpers, and wired the panel to a memory collection. Background emits sync on put/delete and on load; panel applies it via collection.utils.receiveSync(messages). One clear, testable contract instead of the old shared-proxy setup.
2. Extension architecture
With a reliable sync story, the next step was to stop every handler writing to the DB. I introduced a single reconciliation loop: tab events become a small set of event types, get enqueued, and one reconciler drains the queue and is the only writer. Tree logic lives in a pure module; the reconciler just “apply event → compute tree → write once.” I also fixed the “second update overwrites parent” bug: tree shape is owned only by create/move/remove; handleTabUpdated no longer overwrites parentTabId when it would incorrectly flatten a tab. Title overrides from the UI persist via patchTab/patchWindow and sync correctly.
Why keep the browser and the tree in sync both ways? It would have been easier to treat the tree as the only source of truth and ignore the browser’s native tab order. I deliberately didn’t. When you move a tab in the browser strip, the tree should reflect that (e.g. “parent moved after its child” → child flattens to root); when you drag in the side panel, the browser strip should reorder to match. So we need two directions: flat list → tree (infer parent/order from browser events) and tree → flat list (depth-first order to tell the browser where to put tabs). The core of that lives in a handful of pure functions.
What those functions do (for anyone reading the code or building something similar):
inferTreeFromBrowserMove(tabs, movedTabId, newIndex). You moved a tab in the browser strip; the flat order changed. This takes the new order and figures out the new tree: who is the moved tab’s parent now (the parent of the tab immediately after it, or root if it’s at the end), which of its children ended up before it in the list and should be “flattened” to the same level, and newtreeOrderkeys (fractional indexing) so order is stable.promoteOnRemove(tabs, removedTabId). A tab was closed. Its direct children need a new parent (the removed tab’s parent) and new order among their new siblings; grandchildren stay where they are. Returns a map of tab id →{ parentTabId, treeOrder }for those direct children only.inferTreeFromBrowserCreate(tabsInWindow, newTabIndex, newTabId). A new tab appeared at a given index in the window. Decide itsparentTabIdandtreeOrderso it slots into the tree (same parent as the tab after it, order between the siblings that are before/after it in the strip).flattenTreeToBrowserOrder(tabs). The other direction: given the tree, produce the depth-first list of tab ids. The reconciler uses this to calltabs.move()so the browser strip matches the tree after a UI drag.
The flow: browser event or UI action → event enqueued → reconciler runs one of these (or applies a patch), gets updated tabs → single write to DB and sync to the panel. No handler writes on its own.
Fix the collections and sync model first, then rewire the extension around a single writer and pure tree logic.
Where things stand now
The issues we were chasing are fixed and the test suite is passing. E2E covers “moving parent tab in native browser after its child,” “moving parent tab between its child and the next tab,” and “db sync” (sidepanel receives windows and tabs from background). There are still flaky edges (e.g. promotion timing), but the core behaviour is stable.
I’m using Tab Canopy daily again with more confidence, fewer surprises, less fragility, and room to iterate without fighting the old architecture. If you’re trying it from the Chrome Web Store, Firefox Add-ons, or from source, this is the release where the tree and sync actually hold up.