DEV Community

Bonzai2Carn
Bonzai2Carn

Posted on • Originally published at ginexys.com

Web Components vs. Iframes: A Hard Lesson in DOM Isolation Barriers

TLDR

A custom element that fetched a canonical app's HTML and swapped document.body.innerHTML looked clean on the surface. It worked until it didn't: the swap raced with the existing app's event handlers, producing a tool that rendered correctly but did nothing. The correct pattern, an iframe pointing at the canonical URL with ?view=, was already in use by the VS Code extension. It took three weeks to apply the same answer to the web.


The Assumption That Seemed Reasonable

Ten SEO landing pages need unique <head> metadata but should load the same tool. A custom element that fetches the canonical tool HTML and injects it into the current page would deduplicate the tool code while keeping per-page metadata. One component, ten thin wrapper pages. Fewer moving parts.


How It Broke

The custom element ran document.body.innerHTML = canonicalBody.innerHTML. This replaced the body after the scripts that loaded the tool had already attached their event handlers to the original DOM.

The app init pattern was DOMContentLoaded → attach handlers → ready. After the innerHTML swap, the DOM the handlers were attached to was gone. The new DOM from the canonical body had no handlers attached. Sometimes the app's deferred script loaded against the old body and ran to completion. Sometimes it ran against the new body. Sometimes it ran twice. The outcome was non-deterministic.

Symptoms: tabs switched. File dialogs opened. Buttons were visually interactive. But clicking "process file" extracted nothing. Clicking "export" produced nothing. Clicking a gated feature opened nothing. No console errors. Everything looked correct and did nothing.


Why It Was Hard to Find

The canonical URL was always used for testing. /tools/pdf-processor/ loaded fine, worked correctly, passed every test.

A Vite plugin had been added to redirect the root canonical URL to /editor/ to prevent a PDF.js worker from hitting the root URL and receiving HTML instead of JavaScript. This redirect routed all user traffic through the wrapper. The standalone test path stopped existing. The bug only appeared on the path users actually took, and that path looked correct visually.


What Was Thrown Away

Three web component files, 8KB of code. The approach was not wrong in principle. It was wrong for an app that does imperative DOM initialization. A custom element that injects static HTML into a page is fine. A custom element that injects a running app into a page, with scripts that have already begun attaching handlers, is not.

Also discarded: the assumption that "deduplicate HTML" means "inject HTML". Deduplication of a running app means isolation, not injection.


What Replaced It

Each SEO landing page became a thin HTML with unique metadata and a single full-viewport iframe:

<iframe src="/tools/pdf-processor/?view=editor"
        style="width:100%;height:100vh;border:none;"
        allow="..."></iframe>
Enter fullscreen mode Exit fullscreen mode

The canonical app loads inside its own document. Its scripts initialize against its own DOM. Nothing races. The ?view=editor query parameter activates the right tab. No DOM swap, no event handler collision.

The VS Code extension had been doing this correctly since the beginning: load the canonical index.html into a webview, inject a global for mode selection, let the app init normally. Three weeks later, the web followed the same pattern.


The Lesson

When you need to embed an app inside another document, use an isolation boundary that the browser already provides. The iframe is the answer. Web components are for components, meaning UI elements that render within the host document's DOM. An existing running app with its own initialization sequence is not a component. Load it at its own origin and talk to it via postMessage or URL parameters.

The sign that you are solving the wrong problem: when your custom element needs to replace document.body.innerHTML, you are trying to do iframe isolation without the isolation.

Top comments (0)