Architecture Background
The project uses a single-spa microfrontend architecture. The host application is main-app, which loads child apps via a runScript function in react-app.js — including analytics-child-app, notification-child-app, comparison-child-app, and others.
Before getting into the problem, two things need to be understood upfront.
First, single-spa has no CSS isolation. I used to assume that since different child apps can run different frameworks, their styles must also be isolated — but that's not the case. single-spa only isolates JavaScript scope. All child apps share the same document.head, so any <style> tag inserted by one child app affects the entire page. Some solutions like qiankun do implement CSS sandboxing, but single-spa does not.
Second, MUI v4 and v5 use two completely different style injection systems. v4 uses JSS — the style tags it produces carry a data-jss attribute in DevTools. v5 switched to Emotion, which injects style tags with data-emotion.
Discovering the Problem
This issue surfaced after main-app completed its MUI v4 → v5 migration.
The symptom: the Dashboard (the root route) had a status indicator that should have been circular. On first load, it looked correct. But after navigating to another route and coming back, it turned into a square — every time, consistently reproducible.
My initial assumption was that something inside main-app was overriding the styles. I spent a while looking through related component styles in the host app and found nothing suspicious.
During debugging, I noticed a key clue: <style> tags with data-jss attributes were appearing in <head> — but not consistently. They weren't there on first load, appeared after navigating away and back, then disappeared again when navigating away. The timing matched the indicator changing shape exactly.
At this point I described the problem to Claude. It immediately pointed in the right direction: this was a style injection conflict caused by JSS and Emotion coexisting — insertion order determines priority. Its guess about the trigger (a lazy-loaded component inside main-app) turned out to be wrong, but the diagnostic direction opened up the rest of the investigation.
The Debug Process
With JSS/Emotion conflict as the working hypothesis, the goal became clear: find out what was triggering JSS injection on route change.
Step 1: Monitor style tag insertion with MutationObserver
Since the problem was timing-related, I ran a MutationObserver in the Console to watch document.head for changes, logging whenever data-jss style tags appeared:
new MutationObserver(() => {
const jssTags = document.querySelectorAll('style[data-jss]');
if (jssTags.length > 0) {
console.log('JSS tag appeared! Count:', jssTags.length);
console.trace();
}
}).observe(document.head, { childList: true });
The moment I navigated back to Dashboard, the Console printed: JSS tag appeared! Count: 5. JSS was being injected at the instant something mounted on the Dashboard.
I also tried console.trace() to follow the call stack, but the bundle was minified — everything showed up as t.a, r.b, and so on. That path was a dead end.
Step 2: Search the host app source for v4 keywords
Next hypothesis: the JSS was coming from either main-app or one of the child apps. Check the host app first.
I searched the entire codebase for MUI v4-related keywords: @material-ui/core/styles, @material-ui, makeStyles, withStyles, createGenerateClassName, jss.
Everything came back empty, or only appeared inside package-lock.json node_modules entries. No v4 remnants in the host app source.
Step 3: Print JSS tag content to identify the source
Step 1 confirmed JSS tags existed, but not what they contained. I wrote a new MutationObserver that printed the content of each tag on insertion — enough to infer which component triggered it:
new MutationObserver((mutations) => {
mutations.forEach(m => {
m.addedNodes.forEach(node => {
if (node.nodeName === 'STYLE' && node.dataset && node.dataset.jss !== undefined) {
console.log('JSS style tag inserted:');
console.log(node.textContent.substring(0, 500));
}
});
});
}).observe(document.head, { childList: true });
The output contained component names including MuiToggleButtonGroup, MuiToggleButton, MuiButtonBase, MuiCircularProgress, and MuiTouchRipple.
At the same moment, a single-spa warning appeared in the Console:
single-spa: analyticsChildApp's rootComponent should implement componentDidCatch
The timing was identical. Suspicion locked onto analytics-child-app.
Step 4: Fetch the bundle directly to confirm the MUI version
I didn't have a local copy of analytics-child-app, but the deployed bundle in the dev environment was directly fetchable. I ran this in the Console:
fetch('/analytics-child-app/index.js')
.then(r => r.text())
.then(t => {
const match = t.match(/@material-ui|@mui\/material/g);
console.log('MUI references found:', [...new Set(match)]);
});
Result: ['@material-ui'].
Root cause confirmed — analytics-child-app's dev deployment was still running MUI v4. It had never been updated.
To verify, I updated analytics-child-app in the dev environment to the master branch (which had already been upgraded to MUI v5), then reproduced the issue. The indicator stayed circular after navigating back. Problem gone.
One question remained: why did the first load look correct if analytics-child-app injects JSS every time it mounts? That gets explained in the next step.
Step 5: Understanding the timing — why Dashboard, why not the first load
I searched the host app source for analyticsActivityFn and found the registration code:
singleSpa.registerApplication(
'analyticsChildApp',
loadReactApp,
analyticsActivityFn,
{ orcStore: store }
);
The third argument controls when analytics-child-app mounts. Its definition:
const analyticsActivityFn = location =>
(location.href.indexOf('/analytics') !== -1) || (location.hash === '#/');
This explained two things.
Why only Dashboard? Dashboard is the root route — location.hash === '#/' matches, so analytics-child-app mounts there. Other routes like Clients and Reports don't match, so it unmounts and its JSS tags disappear.
Why was the first load fine? The first time Dashboard loads, single-spa starts fetching the analytics-child-app bundle asynchronously. While it's downloading, no components have rendered yet, no JSS has been injected, and Emotion has already written its styles — so the indicator stays circular.
When navigating away, analytics-child-app unmounts. On returning to Dashboard, the bundle is now cached. analytics-child-app mounts almost instantly, JSS injects immediately after Emotion's tags, wins the cascade, and the indicator turns square.
The first load was fine because of the download delay. Subsequent loads broke because caching eliminated that delay.
Root Cause and Fix
Root cause: analytics-child-app's dev deployment was an outdated bundle running MUI v4. Every time it mounted, JSS injected and overrode the host app's Emotion styles.
Fix: Re-deploy analytics-child-app from master to the dev environment.
Key Takeaways
MUI semantic class names are why v4 and v5 conflict
MUI components render DOM nodes with two kinds of classes. One is dynamically generated — JSS produces something like makeStyles-root-123, Emotion produces css-abc123. These don't overlap between engines.
The other kind is semantic class names like .MuiButtonBase-root — hardcoded by MUI, identical in v4 and v5.
The conflict happens here. v5 Emotion injects border-radius: 50% for .MuiCircularProgress-root. v4 JSS injects border-radius: 0 for the same class. Both rules have identical specificity. JSS inserts after Emotion, so JSS wins. The indicator turns square.
single-spa has no CSS isolation
single-spa's isolation is limited to JavaScript scope. All child apps share document.head, and any style tag inserted by any child app affects the entire page globally. CSS isolation requires something like qiankun's sandbox or a manual Shadow DOM implementation. This conflict only surfaced because both the host app and child app were using MUI, at different versions. If child apps used completely different CSS approaches with no overlapping class names, there would be no conflict.
How single-spa mount and unmount work
single-spa determines which child apps should be active based on the current route. On every route change, child apps that no longer match their activity function are unmounted; those that match are mounted. When MUI v4's JSS unmounts, it cleans up all the style tags it injected. When it mounts again, it re-injects them. This is why data-jss tags appear and disappear with route changes.
JSS and Emotion manage style tag lifecycles differently
JSS cleans up its injected style tags on unmount and re-injects on mount. Emotion does not clean up — its style tags stay in <head> once inserted. This is why data-jss tags come and go, while Emotion's tags persist.
Caching makes bugs behave counter-intuitively
The first load appeared correct because downloading the bundle took time — Emotion had already finished rendering before JSS could inject. On subsequent loads, the cached bundle loaded instantly, JSS injected immediately, the delay vanished, and the conflict appeared.
If a bug only shows up on the second load, not the first, think about caching.
Debugging child apps without source access
When you don't have a local copy of a child app, you don't need to ask someone for the code. Fetch the deployed bundle directly and scan it with a regex:
fetch('/some-child-app/bundle.js')
.then(r => r.text())
.then(t => console.log([...new Set(t.match(/@material-ui|@mui\/styles|makeStyles/g))]))
MutationObserver is the right tool for dynamic style injection
The Elements panel in DevTools shows a static snapshot — it can't tell you when something was inserted. A MutationObserver watching document.head with childList: true captures the exact moment style tags are added or removed. For any problem involving dynamic style injection, this is where to start.
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)