In April 2014, a developer wanted to sort Hacker News posts by points, time, or comments. Nothing existing did it without clutter, so he wrote a Chrome extension in a couple of days. Ten years later, he rewrote it from scratch with TypeScript, React, tests, and Manifest v3. The extension, Hacker News Sorted, now at v2.6.1, has one hidden dependency: the exact HTML structure of HN's front page.

The Selector War

HN's front page is one big . Each story is three s: title, info, spacer. The extension uses nth-child arithmetic that hasn't changed 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)',

The real trap is anchoring to the outer table. Until March 2026, two anchors were absolute paths through HN's table soup:

// 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',

On March 11, 2026, HN added a logo row to its outer table. Both anchors stopped matching. The extension silently did nothing on every user's machine. The developer committed a hotfix at 17:54, bumping indices by one. But an extension fix reaches users only after Chrome Web Store review.

At 18:47, he replaced positional paths with semantic anchors using :has() and direct-child combinators:

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

These still make bets, but on names HN's own CSS depends on. If HN renames #hnmain, the extension breaks alongside HN's own CSS.

When Selectors Fail Anyway

Version 2.4.0 added a sanity check. The content script waits for HN's DOM with a MutationObserver, then verifies the table body exists before injecting:

// 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;
};

If the story table is missing, setLayoutStatus(false) writes a flag to chrome.storage.sync. A 3-second timeout covers the case where the parent never appears. The background service worker reads this flag and shows a red :( badge on the extension icon:

// background.ts (v2.6.1)
function updateBadge(ok: boolean) {
  chrome.action.setBadgeText({ text: ok ? '' : ':(' });
  chrome.action.setBadgeBackgroundColor({ color: '#E05050' });
  chrome.action.setBadgeTextColor({ color: '#FFFFFF' });
}
chrome.storage.sync.onChanged.addListener((changes) => {
  if (SETTINGS_KEYS.LAYOUT_OK in changes) {
    updateBadge(changes[SETTINGS_KEYS.LAYOUT_OK].newValue !== false);
  }
});

Users see the badge and a popup 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 bug reports; a silent one gets uninstalled.

The GitHub Action Canary

The badge tells users something broke, but not the developer. So the repo has a daily canary:

name: HN Layout Monitor
on:
  schedule:
    - cron: '0 9 * * *'
  workflow_dispatch: {}
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, and runs integration tests. If a selector breaks, the tests fail and a GitHub email lands in the developer's inbox. The tests pin row counts, element contents, and even formats:

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);
});

If HN drops the Unix timestamp from the age element, time sort breaks and the regex catches it. The canary buys a 24-hour detection bound instead of unbounded ignorance.

The Canary That Needed Watching

In July 2026, the monitor started failing every day. The cause wasn't HN's layout. The updater also refreshed a comment-page fixture, which pinned a hardcoded item ID. HN had removed that thread, returning 429 forever. The fix: stop pinning data, instead pick the most-commented story off the fresh homepage.

Why Not the API?

HN has an official API, but the extension doesn't need data — it needs to rearrange the page you're already looking at. Every number is already in the rows, including true timestamps. Fetching the same 30 items would add latency, wider permissions, and a privacy story to explain.

The Real Lesson

This 12-year story isn't about sorting. It's about building for a DOM you don't control. Use semantic selectors tied to names the site's own CSS depends on. Detect failure visibly — a red badge beats silent breakage. Automate detection with a daily canary. And assume your process will die at any moment: write state to storage, read it back on wake.

If you maintain a scraper or extension, steal these patterns. Your users will thank you when the site changes and they see a :( instead of nothing.