Keeping code alive on a DOM you don't own: 12-year Chrome extension story
DEV Community

Keeping code alive on a DOM you don't own: 12-year Chrome extension story

Keeping code alive on a DOM you don't own: 12-year Chrome extension story

In April 2014 I wanted to sort posts on the Hacker News front page by points, by time, or by comments. Nothing I found did it without cluttering the page, so I wrote a tiny Chrome extension over a couple of days, uploaded it to the Web Store, and mostly stopped thinking about it for ten years.

I touched it exactly once in that decade, in 2019, to fix the selectors. Then in March 2024 I rewrote it from scratch, with the old version still working fine: I wanted TypeScript, React, and tests, and Manifest v3 while I was in there. That's when I started actually maintaining it.

It's called Hacker News Sorted, it's now at v2.6.1, and it has one dependency that never appears in package.json: the exact HTML structure of the Hacker News front page. HN can change that structure any day, without a changelog, and they owe me nothing.

Sorting 30 rows of a table is a weekend project. This post is about everything else: the selector that survived a layout change and the one that didn't, the badge that tells users it's my fault, the GitHub Action that checks HN's HTML every morning, and the timestamps HN quietly rewrites.

Why scrape the DOM?

The obvious question first: HN has an official API, so why scrape the DOM at all? Because the extension doesn't need data, it needs to rearrange the page you're already looking at. Every number it sorts by is already sitting in the rows, true timestamps included. Fetching the same 30 items over the network would only add latency, a wider permission surface, and a privacy story to explain.

The selector war

HN's front page is one big <table>. Each story is three <tr>s, emitted in order: a title row, an info row (points, age, comments), and an empty spacer row. The natural way to grab them is nth-child arithmetic, and that part hasn't needed a single change since the 2024 rewrite:

// app/constants.ts (v2.6.1)
TITLE_ROWS : ' tr:nth-child(3n+1) ',
INFO_ROWS  : ' tr:nth-child(3n+2) ',
SPACER_ROWS: ' tr:nth-child(3n+3) ',

Each 3n+k picks every third row from offset k: titles at 1, 4, 7, info rows at 2, 5, 8, spacers at 3, 6, 9. The pattern assumes nothing non-story slips between the rows. HN's one exception, the "More" link at the bottom, sits after all 30 stories, so it never shifts the count above it.

The trap is what those selectors run against. Until March 2026, my two outer anchors were absolute paths through HN's table soup:

// app/constants.ts, the anchors before March 2026 (v2.3.1)
CONTROL_PANEL_PARENT: ' body > center > table > tbody > tr:nth-child(2) > td > table > tbody > tr > td:nth-child(3) ',
TABLE_BODY         : ' body > center > table > tbody > tr:nth-child(4) > td > table > tbody ',

Every nth-child(N) in there is a bet that HN will never insert a row above row N. On March 11, 2026, HN added a logo row to its outer table and won the bet. Both anchors stopped matching, and the extension didn't crash or throw. It just silently did nothing, on every user's machine at once.

I committed a hotfix at 17:54 that day: bump the shifted indices by one. Committed, not delivered: an extension fix reaches users only after the Chrome Web Store review approves the update, so every hour of my reaction time has Google's review time stacked on top of it.

Then I sat with the diff and realized I was patching the symptom. The real problem was anchoring to positions when HN's markup is full of names that have barely moved in two decades (.pagetop is in the earliest Wayback Machine capture of HN, from 2007). At 18:47, 53 minutes after the hotfix, I replaced the positional paths with semantic anchors.

My first pass at that was #hnmain tr:has(.hnname) > td:last-child. (:has() matches an element by what it contains, the one relational selector CSS gives you.) It felt clever and it was wrong: tr:has(.hnname) matches any row with .hnname anywhere inside it, and HN nests tables inside tables, so an outer wrapper row matched along with the nav row it contains. The sort menu injected itself above the page header.

The version that stuck, two days later, pins every step with direct-child combinators:

// app/constants.ts (v2.6.1)
CONTROL_PANEL_PARENT: ' #hnmain tr:has(> td > .pagetop > .hnname) > td:last-child ',
TABLE_BODY         : ' #hnmain #bigbox > td > table > tbody ',

Read the first one aloud: the cell next to the nav that contains the site name. It no longer cares how many wrapper tables HN nests around that row, and it can't accidentally match a parent, because every hop is >.

I want to be honest about what this buys. These are still bets. td:last-child is positional; the tbody chain still encodes nesting. The difference is what the bets are on: names HN's own stylesheet depends on, plus a few explicit local hops, instead of a count of rows in a table I can't see change. HN renaming #hnmain would break me; HN renaming #hnmain would also break HN's own CSS, so we'd at least go down together.

And the new anchors have survived exactly zero HN layout changes so far, because HN hasn't shipped one since. The positional paths survived two years before losing. Ask me in 2028.

One boundary on all of this: it works because HN's markup is unusually stable and hand-named. On a bundler-built SPA with hashed class names there are no names worth anchoring to, and you're down to ARIA roles, text content, and luck.

When the selectors fail anyway

So v2.4.0 added the assumption that the new anchors will eventually lose too. The failure mode that scares me isn't a selector that throws; it's one that matches nothing while I have no way to know.

The content script waits for HN's DOM with a MutationObserver on document.body (the browser API that calls you back whenever the DOM changes), then runs a sanity check before injecting anything:

// content.tsx (v2.6.1)
const verifyAndInject = (
  parent: HTMLElement,
  resolve: (el: HTMLElement) => void
): boolean => {
  if (!getTableBody()) {
    setLayoutStatus(false);
    return false;
  }
  setLayoutStatus(true);
  resolve(injectRootElement(parent));
  return true;
};

The content script hands Plasmo (the extension framework) a Promise that will deliver the element to render into; the resolve here is that Promise's resolve. If the panel's parent shows up but the story table doesn't, the layout has shifted in a way I can't handle, and setLayoutStatus(false) writes a flag to chrome.storage.sync. A 3-second timeout covers the other case: if the parent never appears at all, same flag, and the panel never renders.

The badge itself can't be painted from the content script: only the extension's background service worker may call chrome.action, and chrome.storage is the bridge between those two worlds. Here's the entire background file, 19 lines:

// background.ts (v2.6.1)
import { SETTINGS_KEYS } from '~app/constants';

function updateBadge(ok: boolean) {
  chrome.action.setBadgeText({ text: ok ? '' : ':(' });
  chrome.action.setBadgeBackgroundColor({ color: '#E05050' });
  chrome.action.setBadgeTextColor({ color: '#FFFFFF' });
}

// Restore badge state on service worker restart
chrome.storage.sync.get(SETTINGS_KEYS.LAYOUT_OK, (result) => {
  if (result[SETTINGS_KEYS.LAYOUT_OK] === false) updateBadge(false);
});

// React to layout status changes
chrome.storage.sync.onChanged.addListener((changes) => {
  if (SETTINGS_KEYS.LAYOUT_OK in changes) {
    updateBadge(changes[SETTINGS_KEYS.LAYOUT_OK].newValue !== false);
  }
});

Users see a red :( on the extension icon, and the popup shows a banner: "Sorting temporarily unavailable :( Hacker News appears to have changed its page layout. We're aware, and a fix is on the way." A broken extension that tells you it's broken gets a bug report. A silent one gets an uninstall.

The two-part structure of that file is Manifest v3 discipline. An MV3 service worker is killed after about 30 seconds of idle, and every global variable dies with it, so the flag's only real home is chrome.storage. The listener at the bottom reacts to changes while the worker happens to be alive (anything but an explicit false counts as healthy). The get at the top runs every time Chrome starts the worker fresh, and it's what puts the badge back after the cases where Chrome resets badge state entirely, like a browser restart or an extension reload. Write state assuming your process is already dead; read it back assuming you just woke up with amnesia.

Scope, for the record: the script matches every news.ycombinator.com page except item pages, which get a separate content script for the comment features.

One wart: at v2.6.1 the check has two false positives. Pages with no story list at all (your profile, the submit form) wait 3 seconds, find no table, and flip the flag. And on a slow-loading page the timeout could flip the flag even after the menu mounted fine, because nothing cancelled it. Both fixes shipped right after this release. Detection code needs the same skepticism as the code it watches.

The GitHub Action that reads HN every morning

The badge tells users something broke. It doesn't tell me, unless I happen to open HN that day. So the repo has a canary, .github/workflows/monitor.yml, and this is the whole thing:

name: HN Layout Monitor
on:
  schedule:
    - cron: '0 9 * * *'
  workflow_dispatch: {}
permissions:
  contents: read
jobs:
  check-layout:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bun run fixture:update
      - run: bun run test:integration

Every morning at 09:00 UTC it downloads the live HN homepage, overwrites the HTML fixture my integration tests run against, and runs the suite. If HN changed something my selectors care about, the suite fails, and GitHub's stock failed-workflow email lands in my inbox. No custom alerting, no dashboard. Nineteen lines of YAML watching a website on my behalf.

The integration tests assert more than "selectors match something." They pin the row counts, the elements inside each row, and even the formats of what's in them:

// app/utils/selectors.integration.test.ts (v2.6.1)
// Canary: validate HN's .age title attribute format ("ISO_DATETIME UNIX_TIMESTAMP")
const titleFormatRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} \d+$/;
infoRows.forEach((row) => {
  const ageEl = getTimeElement(row);
  if (!ageEl) return;
  const title = ageEl.getAttribute('title');
  expect(title, 'age title should match "ISO UNIX" format').toMatch(titleFormatRegex);
});

// Canary: validate HN's .age text format ("X minute(s)/hour(s)/day(s) ago")
const ageTextRegex = /^\d+ (minute|hour|day)s? ago$/;
infoRows.forEach((row) => {
  const label = getTimeElement(row)?.textContent?.trim();
  if (label) expect(label, 'age text should match "N unit(s) ago"').toMatch(ageTextRegex);
});

If HN drops the Unix timestamp from the title attribute, time sort breaks and the first regex catches it. If "7 hours ago" ever becomes "7h", the second one fails before I've written a parser that cares.

A daily cron doesn't buy "before the first user notices." If HN ships a change at noon, users hit it all afternoon and my email arrives the next morning. What the canary really buys is a bound: I find out in at most a day, instead of whenever I next happen to open HN with my own extension working. For a one-person project, turning "unbounded" into "24 hours" is most of the value.

And then the canary itself broke

In July 2026 the monitor started failing every day, and the cause wasn't HN's layout. The updater also refreshes a comment-page fixture (the comment features later in this post are tested the same way), and that fixture pinned a hardcoded item id. HN had removed that thread, and their server answered every request for it with 429, forever. The watchdog needed watching.

The fix was to stop pinning data I could derive: the updater now picks the most-commented story off the fresh homepage each run, sends a browser User-Agent, and backs off politely when HN rate-limits. It's 75 lines if you want the fetch half of the pattern too. If your canary hardcodes anything about the site it watches, that's where it will rot.

If you maintain anything that scrapes or decorates someone else's site, steal this pattern. A cron workflow, a fixture refresh, and your existing tests.

One gotcha before you do: GitHub automatically disables scheduled workflows in a public repository after 60 days without repository activity. A canary guarding a project you consider finished is exactly the workflow that dies of that, so an occasional commit (or a calendar reminder to re-enable) is part of the deal. And a risk I haven't paid yet: the canary fetches HN from GitHub's shared runner IPs, exactly the kind of traffic a site might one day rate-limit wholesale.

The timestamps HN rewrites

HN has a "second chance" pool: moderators and reviewers pick good stories that got no traction, and the site re-ups them onto the front page later. When that happens, HN rewrites the story's displayed age. Not to the re-up moment, even: dang has explained it's back-computed, the time the story would have needed to be posted to deserve its new rank. A story submitted four days ago can honestly say "1 hour ago" on the front page.

It isn't rare, either, at least not on the day I checked. Here's a row from the snapshot of the front page I took while writing this (July 16, 2026):

<span class="age" title="2026-07-12T17:19:47 1783876787">
  <a href="item?id=48882777">1 hour ago</a>
</span>

The title attribute is the tell: HN rewrites the visible text, but the machine-readable timestamp keeps the original submission time, as an ISO datetime plus a Unix epoch. (Don't take the attribute's word for it: the official API's time field for that item returns the same 1783876787.)

You can count the rewrites on today's front page yourself. Paste this into DevTools on news.ycombinator.com:

const now = Date.now() / 1000;
const HOURS_PER_UNIT = {
  minute: 1/60,
  hour: 1,
  // ...
};

Comments

No comments yet. Start the discussion.