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.

