7 min read
Fighting Recipe-Site Ads Without Fighting the Page

The engine from the previous articles gives me a clean Recipe. Now I have to render it on the page, over a live recipe site that is actively hostile to anything covering its ad inventory. This is where the old version of the extension went to die, and where I think the rebuild’s most interesting design call lives.

The old approach, and why it was a war it couldn’t win

The original extension tried to make the recipe page nice. It would find the recipe content and remove everything else — ads, popups, the autoplaying video, the life story. Default-show: reveal the good stuff, delete the bad stuff.

The trouble is that recipe sites fight back, automatically. Remove an ad container and the page’s own scripts re-inject it. Hide a sticky element and a MutationObserver on their side puts it back. So the extension escalated: it started overriding eval, Function, setTimeout, XMLHttpRequest, and MutationObserver to suppress the page’s behavior, and re-scrubbed the entire document on a 200ms interval to catch whatever slipped through. That file — helpers/dom.ts — grew to ~1,475 lines and became the single biggest source of cross-browser flakiness in the project. It was fighting the page’s JavaScript with more JavaScript, in the page’s own context, forever.

The whole architecture was a losing position because it picked the wrong default. If your job is to remove hostile content, you’ve signed up for whack-a-mole against an adversary who automates the moles.

The inversion: occlusion, not removal

Here’s the move. Instead of removing the page’s content (default-show), cover it (default-hide). Render the reader as an opaque, full-viewport overlay in an isolated shadow root, and leave the page completely untouched underneath.

The page can do whatever it wants behind the overlay — re-inject ads, fire timers, mutate the DOM on an interval — and none of it is visible, because there’s an opaque surface on top. I never have to suppress the page’s behavior, which means I never need to override a single global. The entire reason dom.ts existed evaporates.

WXT makes the isolated surface a one-liner. createShadowRootUi mounts a Vue app inside a shadow root, and cssInjectionMode: "ui" injects Tailwind into that root rather than the host page, so my styles and the site’s styles can’t touch each other:

ui = await createShadowRootUi<App>(ctx, {
    name: "readable-recipes-root",
    position: "inline",
    anchor: "body",
    append: "last",
    onMount(container, _shadow, host) {
        const app = createApp(ReaderApp, { result, onClose: close });
        app.mount(container);
        restores.push(lockScroll());
        restores.push(keepOnTop(host as HTMLElement));
        restores.push(pauseMedia());
        return app;
    },
    onRemove(app) {
        app?.unmount();
    },
});

The single mutation I make to the host page is locking background scroll, and even that is reversible — every countermeasure returns its own restore function, collected in restores and run on close.

Residual problems occlusion doesn’t solve

Occlusion kills the visibility war, but two things leak through, and being honest about them is the point.

1. Z-index competition. An opaque overlay only occludes what paints below it. A page that inserts a max-z-index element into <body> after my host wins the paint order (equal z-index → later DOM node wins), and some ad scripts do exactly this, on an interval. So I keep one narrow countermeasure: keep my shadow host the last child of <body> at max z-index. The critical detail is how unlike the old approach it is — it’s a single MutationObserver watching only childList, throttled to one requestAnimationFrame, doing one thing:

export function keepOnTop(host: HTMLElement): () => void {
    host.style.zIndex = "2147483647";
    let scheduled = false;
    const ensureLast = () => {
        scheduled = false;
        if (document.body.lastElementChild !== host) {
            document.body.appendChild(host);
        }
    };
    const observer = new MutationObserver(() => {
        if (!scheduled) {
            scheduled = true;
            requestAnimationFrame(ensureLast);
        }
    });
    observer.observe(document.body, { childList: true });
    return () => observer.disconnect();
}

No global overrides. No full-document scrub. One observer, one job, fully reversible via observer.disconnect().

2. Background media. An autoplaying video keeps playing behind an opaque cover — you can’t see it, but you can hear it. So there’s a pauseMedia pass that pauses currently-playing media and remembers what it paused, so close can resume it. Again: targeted, reversible, no overrides.

The hard line, written into the top of the countermeasures file so future-me doesn’t cross back over it:

NO global API monkeypatching (eval/Function/timers/XHR/MutationObserver overrides) and NO interval full-document scrubs. Each function is specific, undoable, and returns its own restore function.

That constraint is the whole lesson from dom.ts. The countermeasures exist; they’re just disciplined.

The ceiling: the browser top layer

Now the honest part, because every approach has a ceiling and pretending otherwise is how you ship something that mysteriously fails on one site.

There is a class of element my overlay cannot cover, no matter how high I crank z-index: the browser’s top layer. When a site calls dialog.showModal() or uses the Popover API, the element is promoted out of the normal painting flow into a separate layer that sits above all normal-layer content. z-index is meaningless against it — z-index only orders things within the normal flow. An iframe wouldn’t help either; it’s still normal-layer.

I hit this on Food Network: their “Legal Terms and Privacy” consent UI is a <dialog> opened with showModal(), so it paints over the reader and keepOnTop is powerless. And honestly — that case is correct to leave alone. Covering a consent dialog doesn’t resolve the legal interaction, it just defers it. But it draws the boundary clearly: an overlay, by construction, cannot beat the top layer. That’s not a bug to fix; it’s a ceiling.

Beating the top layer requires one of two things an extension overlay can’t do: browser privilege (impossible for us), or removing the page entirely via navigation.

What’s shipping, and the spike I might climb to

So here’s where I’ve actually landed, stated plainly: I shipped the shadow-root overlay. It wins the normal-layer fight — the late-injected max-z ads, the scroll-jackers, the autoplay video — cleanly and reversibly, with none of the global-override fragility that sank the old version. Top-layer consent modals show over it. I’m treating that as acceptable for now and watching how it holds up on real sites.

The likely upgrade — and I want to be clear it’s a spike I haven’t committed to, not a settled “right answer” — is to stop overlaying the page and instead navigate the current tab to an extension page that renders the same reader. Unload the recipe page, and the entire ad / top-layer / media fight just… disappears, because the hostile page is no longer loaded. That’s literally how the browser’s own Reader View hides everything: it replaces the page rather than covering it. Back-button returns you to the original site, which is the toggle UX I want anyway, and it pays off even more on mobile, where a consent dialog on a 390px screen is brutal.

The reason I built the overlay first isn’t that I think it’s superior — it’s that almost everything (the engine, the Vue components, settings, messaging) is identical either way. The navigation approach swaps only where the component tree mounts and where its data comes from, so building the overlay first means that future spike is wiring, not a rewrite. I kept ReaderApp a pure function of its result prop precisely so I can render it on an extension page fed from storage instead, the day I decide to climb that rung.

For now: occlude, don’t fight. See how far it gets. Upgrade if the page wins.

The next article steps back from the product entirely to talk about something I made non-negotiable from day one — how all of this gets tested.