DEV Community

SuperLede
SuperLede

Posted on

What YouTube's single-page app did to my Chrome extension

I run a small site that checks whether a YouTube channel is in the Partner Program. A few days ago I shipped a Chrome extension for it. The idea fits in one sentence: put a "Check monetization" button under the channel name, click it, and see the verdict plus the signals behind it.

Adding the button was the easy part. Keeping it on the page, attached to the right channel, was not. Nearly every problem traced back to one thing: YouTube is a single-page app, and it doesn't behave like the ones I've built.

Here's what I ran into.

1. The old page is still there, just hidden

If you look for the channel header with querySelector, you can get the previous channel's header. When you navigate inside YouTube, the page you left isn't removed. It stays in the DOM with a hidden attribute so Back can swap it in again.

So every anchor I mount on starts from the visible page:

const ANCHORS = {
  channel: "ytd-browse:not([hidden]) yt-page-header-renderer yt-flexible-actions-view-model",
  video: "ytd-watch-flexy:not([hidden]) ytd-watch-metadata #owner",
};
Enter fullscreen mode Exit fullscreen mode

The same goes for "is my button still mounted?". Checking isConnected isn't enough, because a node can be perfectly connected inside a hidden page. The question has to be "is it attached to the anchor on the page you can actually see?"

2. The URL is the only thing that tells the truth

The <meta> tags in the head don't update when you navigate inside YouTube. The page you first landed on keeps describing itself while you watch something else.

So the content script never reads page metadata to decide what to check. It takes location.href at the moment of the click, normalizes it, and sends that.

3. yt-navigate-finish isn't enough on its own

YouTube fires yt-navigate-finish after in-app navigation, and that's the obvious hook. But YouTube also re-renders the header without navigating anywhere, and when it does, my button goes with it.

I ended up with two triggers: the event, plus a one-second poll that does two cheap checks. If the URL changed, remount. If the URL is the same but the button isn't on the visible anchor anymore, put it back.

Mounting retries every 250 ms and gives up after 15 seconds. If YouTube ships a redesign and the anchor disappears, the button just doesn't show up. Nothing throws, and the toolbar popup still works. I care about that more than it might sound: an extension that starts throwing errors on every page load after a YouTube redesign gets uninstalled.

4. Results can arrive for a page you've already left

A check takes a few seconds. People click Check and then click the next video. Without care, the first channel's result lands on the second channel's page.

Every request carries an incrementing number, and navigation bumps it. When a result comes back, it's discarded unless its number is still the latest and the URL still matches.

5. A closed shadow root, with all: initial

YouTube's CSS is everywhere and very specific. Without isolation, a button you insert picks up YouTube's font sizes and line heights. Mine lives in a closed shadow root, and the host element gets all: initial so nothing cascades in from the page. Positioning is the only thing set on the host.

6. Network calls go through the service worker

The popup can call fetch itself, but then closing the popup halfway through a check kills the request. The server has already counted it against the daily limit, the result never gets cached, and reopening the popup costs the user another check.

So the popup and the in-page button both message the service worker, and it makes the request. That brought two more things with it. If the popup and the button ask about the same channel at the same moment, only one request goes out. And results are cached for ten minutes, so reopening one doesn't spend another check.

The service worker only accepts messages from its own popup and from top-frame content scripts on youtube.com. Anything else is dropped. Requests go out with credentials: "omit", and a timeout is never retried automatically, because the server may have counted it already.

7. Updates orphan your content scripts

When the extension updates or is reloaded, content scripts already running in open tabs keep running but can no longer talk to the extension. chrome.runtime.id becomes undefined. My scripts check for that first, and if it's gone they remove their button and stop polling. Otherwise you're left with a button that does nothing when clicked.

The opposite case: Chrome doesn't inject content scripts into tabs that were already open when the extension was installed. So when you open the popup on a YouTube tab where the content script isn't answering, it tells you to refresh the tab. Without that line, "the button doesn't appear" would be the first bug report.

Why the check runs on a server

The obvious design is to do it all in the browser. You're already on the page, and the data is right there.

I didn't, for two reasons. One of the strongest public signals is whether ads are actually being served on a channel's recent long videos, and in the user's browser that signal is bent by things the extension can't see: YouTube Premium, ad blockers. A server check gives everyone the same answer for the same channel.

The other reason is that the obvious marker in the page doesn't mean what people assume. Several extensions read one flag from the page source and print "monetized". In a small test it showed up on 5 of 19 channels that weren't in the program. YouTube has run ads on non-partner channels since late 2020 in the US and 2021 elsewhere, so seeing ads, or ad plumbing, proves little by itself.

So the extension is a thin client. The server reads public pages, scores the signals and returns a verdict with each signal marked pass, fail or couldn't read.

Privacy, briefly

Nothing is sent while you browse. A channel or video link goes out only when you click Check, open the popup on a channel or video page, or paste a link, and only after you've said yes once.

Checks from the extension also don't feed the public statistics on the site. That one cost a separate table on the server. The shared cache also drives a weekly dataset and a refresh list, and I didn't want extension usage quietly reshaping either of them.

A pack script that says no

Chrome Web Store review takes long enough that I didn't want to find problems there. The pack script refuses to build if the manifest has keys it doesn't expect, if there's an inline script or inline event handler anywhere, or if anything looks like remote code. For the store build it also strips the development key and checks that the API URL points at production. A rejection costs days, and the script costs seconds.

What I'm watching

Two things: whether the button survives the next few YouTube header changes, and what share of real checks come back "unable to determine". That second number is the failure mode I care about most.

If you've built something that lives inside YouTube's DOM, I'd like to know how you handle remounting. A one-second poll works, but it feels like there should be something better.

The extension is free: Monetization Checker for YouTube. More on how it works on the extension page.

Top comments (0)