← cd /blog

Article

Replacing Dia's New Tab Page by Intercepting chrome://start-page/

·
buildstools

In February 2026, Dia opened new tabs at chrome://start-page/<UUID>, its built-in chat page, and ignored chrome_url_overrides.newtab. An extension that declares the override installed cleanly and changed nothing. A tabs.onUpdated listener that redirects that URL did the job.

The whole of background.js:

// background.js
// Redirects new tabs to our extension page (workaround for Dia browser
// which doesn't honor chrome_url_overrides.newtab)

const NEWTAB_URL = chrome.runtime.getURL('newtab.html');

chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
  if (changeInfo.url && (changeInfo.url.startsWith('chrome://start-page/') || changeInfo.url === 'chrome://newtab/')) {
    chrome.tabs.update(tabId, { url: NEWTAB_URL });
  }
});

The URL ends in a UUID, so match the prefix, not the whole string. The manifest registers the worker and asks for tabs:

{
  "manifest_version": 3,
  "name": "Dia Home",
  "version": "1.0.0",
  "description": "A bookmarks home page for Dia browser",
  "permissions": [
    "bookmarks",
    "storage",
    "favicon",
    "tabs"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "chrome_url_overrides": {
    "newtab": "newtab.html"
  },
  "icons": {
    "16": "icons/icon-16.png",
    "48": "icons/icon-48.png",
    "128": "icons/icon-128.png"
  }
}

Note: Chrome's tabs API docs list url and pendingUrl among four sensitive tab properties that need the tabs permission or host permissions. The listener reads the URL, so keep tabs.

The start-page URL came from DevTools.

A new tab and a target="_blank" link look the same to onCreated

Claude wrote nearly all of this extension. Its attempts at background.js in commit order, five commits between 14:06 and 15:19 on 13 February:

  • chrome_url_overrides.newtab alone. Dia loaded the extension and ignored the override.
  • tabs.onCreated, redirecting any tab with an empty URL. Cmd+click and "Open in new tab" also create tabs with no url for a moment. Those got redirected too.
  • webNavigation.onBeforeNavigate. Added to cut the flash of Dia's own page before the redirect. Reverted 72 seconds later as slower than tabs.onCreated. It also needed the extra webNavigation permission.
  • tabs.onCreated with a pendingUrl guard. It still can't tell a blank new tab from a target="_blank" link. Both arrive with identical empty properties.

tabs.onUpdated fires with the URL Dia navigates to. The listener matches that URL instead of guessing from an empty tab.

Store bookmark IDs and let Chrome supply the rest

The extension pins bookmarks as tiles in sections. It adds drag-to-reorder, a grid/list toggle, bookmark search and a search bar that cycles Google, DuckDuckGo and Brave. The UI is vanilla JS with no build step: js/storage.js, js/bookmarks.js, js/app.js and css/style.css.

The pin list in chrome.storage.sync holds only IDs, order and section, and it syncs across devices. URLs and titles come from chrome.bookmarks on every load. The one exception is a tile renamed inside the extension: its title is kept under a separate customTitles key in chrome.storage.sync. A pin:

{ id: "bookmark_id", order: 0, sectionId: "default" }

A section takes its ID from the clock (js/storage.js, addSection):

const id = 'sec_' + Date.now();
sections.push({ id, name: name || 'New Section', order: maxOrder + 1 });

Favicons come from Chrome's _favicon endpoint, which needs the favicon permission:

getFaviconUrl(pageUrl) {
  return `chrome-extension://${chrome.runtime.id}/_favicon/?pageUrl=${encodeURIComponent(pageUrl)}&size=32`;
}

The grid is repeat(auto-fill, minmax(100px, 1fr)), so tiles reflow at any width.

A failing fallback refires onerror until the handler nulls itself

A favicon that fails to load gets a generated SVG instead: the first letter of the title on a coloured square. If that fallback also fails, onerror fires again and sets the same fallback again, endlessly.

// WRONG (first version, data URI trimmed): nothing stops a second call
img.onerror = () => { img.src = 'data:image/svg+xml,<svg ...>' + (item.title[0] || '?').toUpperCase() + '</text></svg>'; };

// CORRECT (js/app.js)
img.onerror = () => { img.onerror = null; img.src = this.fallbackFavicon(item.title); };

An audit of the code found this bug and the two below. All three fixes landed in the same commit as the sections feature.

Renumbering by array position reshuffles tiles after a drag

The drop handler rewrites each pin's order field and then saves the array in its old sequence, so after a drag the array position and order disagree. Unpinning a tile renumbers the rest of its section. Renumbering by array position reshuffles them, with no error.

// WRONG once tiles can be dragged (first version): array position
pinned = pinned.filter(p => p.id !== bookmarkId);
pinned.forEach((p, i) => p.order = i);

// CORRECT (js/storage.js, removePin): this section only, sorted first
const inSection = pinned.filter(p => (p.sectionId || 'default') === sectionId);
inSection.sort((a, b) => a.order - b.order);
inSection.forEach((p, i) => { p.order = i; });

Moving a tile to another section has its own ordering trap. Read the target section's items before you change the dragged tile's sectionId. Change it first and the filter returns the dragged tile too, so the splice inserts it a second time.

// js/app.js, cross-section drop
const targetSectionItems = App.pinnedData
  .filter(p => (p.sectionId || 'default') === targetSection)
  .sort((a, b) => a.order - b.order);
const targetLocalIdx = targetSectionItems.findIndex(p => p.id === targetId);

dragged.sectionId = targetSection;

targetSectionItems.splice(targetLocalIdx, 0, dragged);
targetSectionItems.forEach((p, i) => { p.order = i; });

Append moved pins after the default section

Deleting a section moves its pins to the default section. Reassigning sectionId alone keeps their old order values: 0, 1, 2. The default section's own pins already hold those. The render sorts by order only. Ties keep their array position, so a moved pin and a default pin with the same order sit side by side. Number the moved pins after the default section's highest order:

// js/storage.js, removeSection
const defaultItems = pinned.filter(p => (p.sectionId || 'default') === 'default');
const maxOrder = defaultItems.reduce((max, p) => Math.max(max, p.order), -1);
const movedItems = pinned.filter(p => p.sectionId === sectionId).sort((a, b) => a.order - b.order);
movedItems.forEach((p, i) => { p.sectionId = 'default'; p.order = maxOrder + 1 + i; });

Use the tile as its own drag image

Tiles are <a> elements with tile.draggable = true. The dragstart handler passes the tile to setDragImage, then adds the .dragging class (opacity 0.4, scale 0.95) in a zero-delay setTimeout:

tile.addEventListener('dragstart', (e) => {
  // ...
  const rect = tile.getBoundingClientRect();
  e.dataTransfer.setDragImage(tile, e.clientX - rect.left, e.clientY - rect.top);
  setTimeout(() => tile.classList.add('dragging'), 0);
});