{"slug": "the-surprising-complexity-of-injecting-a-ui-into-someone-else-s-web-page", "title": "The Surprising Complexity of Injecting a UI into Someone Else's Web Page", "summary": "Parvej Shah's team at Leadswave, a LinkedIn brand assistant Chrome extension, overcame significant technical hurdles including style collisions, service worker lifecycle issues, and Manifest V3 constraints. They used Shadow DOM for style isolation and direct API calls from content scripts to handle service worker termination, while also optimizing AI context extraction to reduce token usage.", "body_md": "Originally published at[parvejshah.com/blog/building-manifest-v3-ai-chrome-extensions]by[Parvej Shah].\n\nWhen 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.\n\nThree 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.\n\nWhen 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.\n\nThe 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).\n\nThe 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.\n\n``` js\nfunction mountAssistantWidget(hostElement: HTMLElement): HTMLElement {\n  const container = document.createElement(\"div\");\n  container.id = \"lw-assistant-root\";\n\n  // Attach a shadow root — this creates the style isolation boundary\n  const shadow = container.attachShadow({ mode: \"open\" });\n\n  // Our styles live inside the shadow — they can't bleed out,\n  // and LinkedIn's styles can't bleed in\n  const styleSheet = document.createElement(\"link\");\n  styleSheet.rel = \"stylesheet\";\n  styleSheet.href = chrome.runtime.getURL(\"content/styles.css\");\n  shadow.appendChild(styleSheet);\n\n  const mountPoint = document.createElement(\"div\");\n  shadow.appendChild(mountPoint);\n\n  hostElement.appendChild(container);\n  return mountPoint;\n}\n```\n\nManifest 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.\n\nThis 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.\n\n```\n// Don't rely on module-level variables for persistent state\n// This will be undefined after the service worker restarts:\n// let cachedApiKey: string | null = null; // BAD\n\n// Instead, always read from storage:\nasync function getApiKey(): Promise<string | null> {\n  const result = await chrome.storage.local.get([\"apiKey\"]);\n  return result.apiKey ?? null;\n}\n\nasync function saveUserSettings(settings: UserSettings): Promise<void> {\n  await chrome.storage.local.set({ userSettings: settings });\n}\n```\n\nFor 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.\n\nThe 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.\n\n``` js\nfunction extractPostContext(postElement: HTMLElement): PostContext {\n  const authorElement = postElement.querySelector(\n    \".update-components-actor__name\"\n  );\n  const author = authorElement?.textContent?.trim() ?? \"Unknown\";\n\n  const bodyElement = postElement.querySelector(\n    \".update-components-text\"\n  );\n\n  const bodyText = extractTextNodes(bodyElement)\n    .filter(text => text.trim().length > 0)\n    .join(\" \")\n    .replace(/s+/g, \" \")\n    .trim();\n\n  return { author, bodyText, extractedAt: Date.now() };\n}\n\nfunction extractTextNodes(el: Element | null): string[] {\n  if (!el) return [];\n  const texts: string[] = [];\n\n  el.childNodes.forEach(node => {\n    if (node.nodeType === Node.TEXT_NODE) {\n      texts.push(node.textContent ?? \"\");\n    } else if (node.nodeType === Node.ELEMENT_NODE) {\n      texts.push(...extractTextNodes(node as Element));\n    }\n  });\n\n  return texts;\n}\n```\n\nThe 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.\n\nThere'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.\n\nWe 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.\n\nThe 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.\n\n*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.*", "url": "https://wpnews.pro/news/the-surprising-complexity-of-injecting-a-ui-into-someone-else-s-web-page", "canonical_source": "https://dev.to/parvejshah/the-surprising-complexity-of-injecting-a-ui-into-someone-elses-web-page-32p", "published_at": "2026-08-26 20:32:53+00:00", "updated_at": "2026-08-26 20:50:07.615281+00:00", "lang": "en", "topics": ["developer-tools", "ai-products", "ai-agents"], "entities": ["Leadswave", "Parvej Shah", "LinkedIn", "Chrome", "Manifest V3", "Shadow DOM"], "alternates": {"html": "https://wpnews.pro/news/the-surprising-complexity-of-injecting-a-ui-into-someone-else-s-web-page", "markdown": "https://wpnews.pro/news/the-surprising-complexity-of-injecting-a-ui-into-someone-else-s-web-page.md", "text": "https://wpnews.pro/news/the-surprising-complexity-of-injecting-a-ui-into-someone-else-s-web-page.txt", "jsonld": "https://wpnews.pro/news/the-surprising-complexity-of-injecting-a-ui-into-someone-else-s-web-page.jsonld"}}