# The Surprising Complexity of Injecting a UI into Someone Else's Web Page

> Source: <https://dev.to/parvejshah/the-surprising-complexity-of-injecting-a-ui-into-someone-elses-web-page-32p>
> Published: 2026-08-26 20:32:53+00:00

Originally published at[parvejshah.com/blog/building-manifest-v3-ai-chrome-extensions]by[Parvej Shah].

When we started building **Leadswave** — a LinkedIn brand assistant Chrome extension — the plan seemed straightforward. Detect LinkedIn post elements in the feed, attach a small AI companion button to each one, let users generate engagement responses without leaving the page. A week's work, maybe two.

Three weeks later we were still fighting style collisions, service worker lifecycle bugs, and a Manifest V3 API constraint we hadn't anticipated. This is what we learned.

When your content script injects HTML into LinkedIn's document, you're working inside a page you don't control, with styles you didn't write, against a DOM structure that changes in LinkedIn's deployments without your knowledge.

The first version of Leadswave simply appended a styled div to each post element and mounted a React component inside it. It worked locally. On the actual LinkedIn feed, our Tailwind utility classes either had no effect (because LinkedIn's CSS specificity was higher) or caused unintended effects in LinkedIn's own components (because our styles bled into their DOM nodes).

The fix is Shadow DOM. Every modern browser supports the ability to attach a shadow root to a host element, creating an isolated DOM subtree that is completely separate from the main document's style cascade.

``` js
function mountAssistantWidget(hostElement: HTMLElement): HTMLElement {
  const container = document.createElement("div");
  container.id = "lw-assistant-root";

  // Attach a shadow root — this creates the style isolation boundary
  const shadow = container.attachShadow({ mode: "open" });

  // Our styles live inside the shadow — they can't bleed out,
  // and LinkedIn's styles can't bleed in
  const styleSheet = document.createElement("link");
  styleSheet.rel = "stylesheet";
  styleSheet.href = chrome.runtime.getURL("content/styles.css");
  shadow.appendChild(styleSheet);

  const mountPoint = document.createElement("div");
  shadow.appendChild(mountPoint);

  hostElement.appendChild(container);
  return mountPoint;
}
```

Manifest V2 allowed persistent background pages — JavaScript modules that stayed alive indefinitely and could hold state in memory. Manifest V3 replaced this with service workers. Service workers can be terminated by the browser at any point when they appear idle.

This is easy to forget when developing locally, because Chrome is less aggressive about terminating service workers during active development. In production, with a real user who opens LinkedIn once, reads through their feed over 20 minutes, and then triggers the assistant widget — the service worker has almost certainly been terminated in the interim.

```
// Don't rely on module-level variables for persistent state
// This will be undefined after the service worker restarts:
// let cachedApiKey: string | null = null; // BAD

// Instead, always read from storage:
async function getApiKey(): Promise<string | null> {
  const result = await chrome.storage.local.get(["apiKey"]);
  return result.apiKey ?? null;
}

async function saveUserSettings(settings: UserSettings): Promise<void> {
  await chrome.storage.local.set({ userSettings: settings });
}
```

For the assistant response generation — which requires sending post context to an API and streaming back a response — the content script makes the API call directly rather than routing through the service worker. This avoids the service worker lifecycle problem entirely for latency-sensitive operations.

The AI generation requires understanding the content of the LinkedIn post the user is looking at. Passing raw innerHTML is wasteful and noisy — LinkedIn embeds tracking attributes, SVG icon paths, interaction counters, and other noise that consumes tokens without contributing to useful context.

``` js
function extractPostContext(postElement: HTMLElement): PostContext {
  const authorElement = postElement.querySelector(
    ".update-components-actor__name"
  );
  const author = authorElement?.textContent?.trim() ?? "Unknown";

  const bodyElement = postElement.querySelector(
    ".update-components-text"
  );

  const bodyText = extractTextNodes(bodyElement)
    .filter(text => text.trim().length > 0)
    .join(" ")
    .replace(/s+/g, " ")
    .trim();

  return { author, bodyText, extractedAt: Date.now() };
}

function extractTextNodes(el: Element | null): string[] {
  if (!el) return [];
  const texts: string[] = [];

  el.childNodes.forEach(node => {
    if (node.nodeType === Node.TEXT_NODE) {
      texts.push(node.textContent ?? "");
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      texts.push(...extractTextNodes(node as Element));
    }
  });

  return texts;
}
```

The extracted context is typically 200 to 400 tokens — clean, structured, and representative of what the post actually says. This keeps API costs predictable and response generation fast.

There's no way to make a content script robustly stable against DOM changes in a host page you don't control. LinkedIn deploys frontend changes regularly, and CSS class names can shift.

We partially mitigate this with attribute-based selectors where possible — data-* attributes tend to be more stable than utility class names — and by maintaining a small compatibility shim that detects structural changes and reports them. When the extension breaks on a LinkedIn update, we want to know within hours, not days.

The real answer is humility: DOM scraping is inherently fragile, and the architecture needs to be designed with that fragility in mind rather than pretending it won't happen.

*Parvej Shah is a Lead Full-Stack Web Developer & Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at parvejshah.com.*
