# Static CSS Leakage in Microfrontends — Why Shadow DOM Fails and How postcss-prefix-selector Fixes It

> This is a follow-up to [MUI v4/v5 Style Conflicts in single-spa — A Complete Debug Record](https://chloezhou.dev/mui-v4-v5-style-conflicts-in-single-spa-a-complete-debug-record). The previous article covered dynamic style conflicts caused by MUI v4 JSS injecting style tags on mount and overriding the host app's Emotion styles. This article covers a different problem that surfaced right after: static CSS leakage.

Both problems look the same on the surface — child app styles leaking into the host app — but the root causes are completely different, and so are the solutions.

* * *

## Discovering the Problem

A UI style anomaly appeared in the host app. Opening DevTools and checking computed styles in the Elements panel, I found a class whose source was listed as `App.css`. Looking inside, it contained hardcoded MUI semantic class names like `.MuiTypography-body1` — rules that were directly affecting the host app's DOM.

There wasn't time to dig into it immediately, so a colleague took a look and suggested using Shadow DOM for isolation. Before implementing it, I started investigating whether Shadow DOM would actually work — and found that it wouldn't.

* * *

## Why Shadow DOM Doesn't Work Here

The idea was to wrap `analytics-child-app` inside a Shadow DOM so its styles would be contained within its own DOM subtree. Styles inside can't leak out; styles outside can't get in.

The problem: Shadow DOM is created when React renders the root component. Static CSS is injected into `document.head` when the bundle loads — which happens *before* Shadow DOM exists. By the time Shadow DOM is created, the styles are already in the page.

To understand why, it helps to look at how Emotion and static CSS work differently.

**Emotion injects styles at runtime:**

```plaintext
React component renders
    ↓
Emotion computes the CSS
    ↓
Creates a <style> tag via JS
    ↓
Inserts it into a target node (controllable via CacheProvider)
```

The insertion target is configurable — which is why Shadow DOM + `CacheProvider` can keep Emotion styles contained inside a Shadow Root.

**Static CSS works in two phases:**

```plaintext
Build time (webpack build / dev server start)
    ↓
css-loader parses the CSS file into a JS-processable string
    ↓
style-loader wraps it in JS code that auto-injects the styles, bundled into the output

Browser loads the bundle (executes immediately)
    ↓
That JS code runs, injecting styles into document.head
```

The code `style-loader` generates looks essentially like this:

```javascript
var style = document.createElement('style');
style.innerHTML = ".MuiTypography-body1 { font-size: 0.875rem !important; }";
document.head.appendChild(style);
```

`document.head.appendChild` is hardcoded by `style-loader`. There's no hook to intercept it. It doesn't go through React. It doesn't go through `CacheProvider`. It runs before any component renders. Shadow DOM hasn't been created yet — it's already too late.

One more thing worth noting: **this injection happens at the bundle level, not the component level.** If a CSS file is imported anywhere in the child app, its styles are injected when the bundle loads — regardless of whether the component that imports it ever renders.

```plaintext
analytics-child-app/
  src/
    App.js      → import './App.css'
    Table.js    → import './Table.css'
    Modal.js    → import './Modal.css'  ← this component may never render
```

When webpack bundles these files, all three CSS files get converted to injection code in the same bundle. The moment the browser loads the bundle, all three files' styles are injected into `document.head` — whether or not any of those components ever render.

The core difference between the two approaches:

|  | Static CSS | Emotion |
| --- | --- | --- |
| When it runs | Immediately on bundle load | At React render time |
| How it inserts | style-loader hardcodes `document.head` | JS API call, target is configurable |
| Interceptable | ❌ No | ✅ Yes, via CacheProvider |

* * *

## Verification

**Step 1: Confirm the styles are in** `document.head`

After `analytics-child-app` had mounted normally, I ran this in the Console:

```javascript
Array.from(document.head.querySelectorAll('style'))
  .find(s => s.innerHTML.includes('MuiTypography-body1'))
```

It returned a style element — confirmed the styles were in `document.head`.

**Step 2: Confirm the injection timing**

Hard-refreshed the page. While the bundle was still loading and before any components had started rendering, I ran the same query immediately. The styles were already in `document.head`.

**Conclusion:** Static CSS injection happens at bundle execution time. The code `style-loader` generates runs as soon as the bundle loads — no component rendering required.

* * *

## The Fix: postcss-prefix-selector

The approach: add the child app's mount point ID as a prefix to all of its static CSS selectors, so the styles only apply within the child app's own DOM subtree.

**Install the dependencies:**

```shell
npm install postcss-loader postcss-prefix-selector --save-dev
```

**Step 1: Find the child app's mount point ID**

The prefix must match the child app's actual mount point ID. If it's wrong, the prefixed selectors won't match any DOM nodes and all styles will stop working.

Check the Elements panel in DevTools for a div with an `analytics`\-related ID, or look in the child app's `src/index.js` for the `domElementGetter`:

```javascript
const domElementGetter = () => {
  let el = document.getElementById('analytics-container')
  if (!el) {
    el = document.createElement('div')
    el.id = 'analytics-container'
    document.body.appendChild(el)
  }
  return el
}
```

The mount point ID is `analytics-container`, so the prefix is `#analytics-container`.

**Step 2: Update the webpack config**

Split the CSS rule into two — one for `node_modules` (no prefix), one for the app's own CSS (add prefix). **Excluding** `node_modules` **is essential** — prefixing third-party library styles will break them.

```javascript
// Rule 1: node_modules CSS — no prefix
{
  test: /\.css$/,
  include: /node_modules/,
  use: ['style-loader', 'css-loader'],
},
// Rule 2: app CSS — add prefix
{
  test: /\.css$/,
  exclude: /node_modules/,
  use: [
    'style-loader',
    'css-loader',
    {
      loader: 'postcss-loader',
      options: {
        postcssOptions: {
          plugins: [
            ['postcss-prefix-selector', {
              prefix: '#analytics-container',
            }]
          ]
        }
      }
    }
  ]
},
```

For SCSS, the same split applies — and `sass-loader` must go last in the `use` array (webpack runs loaders right to left).

**Step 3: Verify the prefix is working**

After rebuilding and redeploying, run this in the Console:

```javascript
Array.from(document.head.querySelectorAll('style'))
  .find(s => s.innerHTML.includes('MuiTypography-body1'))
  .innerHTML
```

If the output shows style rules with the `#analytics-container` prefix, the config is working.

**How webpack processes these rules:**

webpack scans rules top to bottom and uses the first match.

`node_modules` CSS matches Rule 1 — `css-loader` reads it, `style-loader` generates the injection code, no prefix added.

The app's own CSS matches Rule 2 — loaders run right to left:

1.  `postcss-loader` reads the CSS and adds `#analytics-container` to every selector
    
2.  `css-loader` processes the prefixed CSS
    
3.  `style-loader` generates the `document.head` injection code
    

End result:

| File | Rule | Prefixed |
| --- | --- | --- |
| `node_modules/simplebar-react/...css` | Rule 1 | ❌ No |
| `node_modules/other-lib/...css` | Rule 1 | ❌ No |
| `src/App.css` | Rule 2 | ✅ `#analytics-container` |
| `src/index.css` | Rule 2 | ✅ `#analytics-container` |
| `src/Toggle.css` | Rule 2 | ✅ `#analytics-container` |

**Two common mistakes:**

1.  **Wrong mount point ID** — the prefix must exactly match the child app's actual mount point ID. Get it wrong and all styles stop applying.
    
2.  **Not excluding node\_modules** — third-party library styles will get prefixed and break.
    

* * *

## Key Takeaways

### Static CSS and dynamic styles are two completely different problems

When debugging microfrontend style conflicts, the first question is which type you're dealing with. Dynamic styles (JSS, Emotion) are injected at runtime. Static CSS is injected immediately when the bundle loads. The root causes are different, the solutions are different, and conflating them leads to wasted effort.

### Why Shadow DOM doesn't work for static CSS

The injection happens at bundle load time — before any React rendering, before Shadow DOM is created. `style-loader`'s generated code hardcodes `document.head.appendChild` with no interception point. By the time Shadow DOM exists, the styles are already in the page.

### Static CSS injection is bundle-level, not component-level

If a CSS file is imported anywhere in a child app, its styles get injected when the bundle loads — whether or not the component that imports it ever renders.

### Two things to get right with postcss-prefix-selector

The prefix must match the child app's actual mount point ID exactly. And `node_modules` must be excluded — otherwise third-party library styles get prefixed and break.

### webpack loaders run right to left

In webpack's `use` array, loaders execute from right to left. `sass-loader` goes last (rightmost), `style-loader` goes first (leftmost). Getting the order wrong causes build errors or styles that don't apply.
