Hybrid mobile apps that render LLM-generated content in WebViews face a unique challenge: untrusted markup streaming into a shared DOM can break layout, leak styles, or enable script injection. Shadow DOM—a Web Components primitive—offers encapsulation boundaries that isolate third-party HTML without iframes or sanitization overhead. When Omar Abu Sharifa shipped OfflineAI's conversational interface in Flutter, Shadow DOM scoping reduced CSS collision bugs by 94% and eliminated three classes of XSS vectors.

The WebView Rendering Problem

Mobile apps embedding LLMs typically stream Markdown or HTML fragments into a WebView for rich formatting. A naive implementation appends each chunk to document.body:

webView.evaluateJavascript(
  "document.body.innerHTML += '" + chunk + "'"
);

This creates three failure modes. First, global CSS rules bleed into the injected content—a .button class in the app's stylesheet inadvertently styles a code block. Second, the LLM-generated HTML can override app styles if it includes <style> tags. Third, any <script> tags in the response execute in the main document context, accessing cookies and localStorage.

Traditional mitigations are expensive. Server-side sanitization with libraries like DOMPurify adds 40–80ms latency per chunk. Content Security Policy headers block inline scripts but also break legitimate onclick handlers in Markdown tables. Iframe sandboxing works but requires postMessage orchestration and doubles memory overhead—measured at 18MB per conversation in production profiling.

Shadow DOM Encapsulation

Shadow DOM attaches an isolated subtree to a host element. Styles defined inside the shadow root do not affect the outer document, and vice versa. The boundary is enforced by the browser's rendering engine, not runtime JavaScript checks.

const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = llmChunk;

The mode: 'closed' parameter prevents external scripts from accessing host.shadowRoot, blocking read-after-write attacks where malicious content probes the DOM structure. Each LLM response gets its own shadow root, creating a per-message isolation boundary.

Style encapsulation is bidirectional. A shadow root can include a <style> tag that applies only to its subtree:

shadow.innerHTML = `
  <style>
    code { background: #f5f5f5; padding: 2px 4px; }
  </style>
  ${llmChunk}
`;

This code rule does not affect syntax-highlighted code blocks in the app's main UI. Conversely, the app's global font-family does not cascade into the shadow root unless explicitly inherited via :host selectors.

Script Injection Defense

Shadow DOM does not execute <script> tags in content assigned via innerHTML. The HTML parser treats them as inert text nodes. An LLM hallucinating <script>alert(1)</script> renders literally, not as executable code.

Event handlers like onclick are similarly neutered. The attribute exists in the DOM but does not bind to a function. This breaks legitimate interactive elements—a Markdown table with sortable columns—but eliminates the most common XSS vector in streaming UIs.

For apps that need interactive content, the solution is explicit event delegation. The host document listens for clicks on the shadow root's container and dispatches custom events inward:

host.addEventListener('click', (e) => {
  const target = e.composedPath()[0];
  if (target.dataset.action === 'copy-code') {
    navigator.clipboard.writeText(target.textContent);
  }
});

The composedPath() method pierces shadow boundaries during event bubbling, allowing the outer app to inspect click targets without direct DOM access.

Constructable Stylesheets

Repeating the same <style> block in every shadow root wastes memory. A 300-message conversation with 4KB of CSS per root consumes 1.2MB. Constructable stylesheets share a single CSSStyleSheet object across roots:

const sheet = new CSSStyleSheet();
sheet.replaceSync(`
  code { background: #f5f5f5; }
  pre { border-left: 3px solid #007aff; }
`);

shadow.adoptedStyleSheets = [sheet];

This reduces per-message overhead to 48 bytes—the pointer to the shared sheet. In OfflineAI's production deployment, constructable stylesheets cut WebView memory by 31% in long conversations.

Cross-Platform WebView Differences

Flutter's webview_flutter plugin wraps WKWebView on iOS and WebView on Android. Both engines support Shadow DOM, but behavior diverges in edge cases. iOS 14.5 introduced a bug where attachShadow on a detached node throws InvalidStateError. The workaround appends the host to document.body before attaching the shadow root, then removes it if needed.

Android's WebView has a subtler issue: :host selectors do not inherit CSS custom properties from the outer document in Chrome 89–92. Apps relying on var(--primary-color) for theming must inject properties directly into the shadow root via inline styles or a dedicated <style> tag.

Performance Overhead

Creating a shadow root takes 0.8–1.2ms on mid-range Android devices (Snapdragon 720G). For streaming LLMs emitting 15 tokens/sec, batching chunks into 50-token groups amortizes the cost to 1 shadow root per 3.3 seconds. Profiling showed this added 2.1% CPU overhead compared to raw innerHTML appends.

Memory overhead is 3.2KB per shadow root on iOS and 4.1KB on Android, measured via performance.memory.usedJSHeapSize. A 200-message conversation with one root per message uses 640KB–820KB, well within mobile budgets.

Debugging and Inspection

Chrome DevTools expands shadow roots in the Elements panel, showing the encapsulated tree with a #shadow-root label. Styles applied within the root appear under a separate "Styles" section. This visibility is critical for debugging layout issues in LLM-generated HTML.

Safari's Web Inspector requires enabling "Show Shadow DOM" in Develop menu preferences. Once active, shadow roots appear as child nodes with a gray disclosure triangle. The Styles pane groups shadow-scoped rules separately from document rules.

Automated testing frameworks like Puppeteer cannot query shadow DOM with standard querySelector calls. The test must pierce the boundary explicitly:

const host = await page.$('div.llm-message');
const shadow = await host.evaluateHandle(
  el => el.shadowRoot
);
const codeBlock = await shadow.$('pre > code');

Fallback Strategies

Older Android WebViews (API level