DEV Community

Cover image for Static CSS Leakage in Microfrontends — Why Shadow DOM Fails and How postcss-prefix-selector Fixes It
Chloe Zhou
Chloe Zhou

Posted on • Originally published at chloezhou.dev

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. 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:

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

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:

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
Enter fullscreen mode Exit fullscreen mode

The code style-loader generates looks essentially like this:

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

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.

analytics-child-app/
  src/
    App.js      → import './App.css'
    Table.js    → import './Table.css'
    Modal.js    → import './Modal.css'  ← this component may never render
Enter fullscreen mode Exit fullscreen mode

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:

Array.from(document.head.querySelectorAll('style'))
  .find(s => s.innerHTML.includes('MuiTypography-body1'))
Enter fullscreen mode Exit fullscreen mode

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:

npm install postcss-loader postcss-prefix-selector --save-dev
Enter fullscreen mode Exit fullscreen mode

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:

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

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.

// 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',
            }]
          ]
        }
      }
    }
  ]
},
Enter fullscreen mode Exit fullscreen mode

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:

Array.from(document.head.querySelectorAll('style'))
  .find(s => s.innerHTML.includes('MuiTypography-body1'))
  .innerHTML
Enter fullscreen mode Exit fullscreen mode

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.

If this helped you, I write about real dev problems at chloezhou.dev — you can subscribe to get new posts by email.

Top comments (0)