Injecting Micro-Frontend CSS in single-spa
Tags: webdev, react, frontend, singlespa
Published: 2023-08-13 22:03:26

Once more we gather together around the fascinating topic of making **Vite** projects work with `single-spa`. Today's topic is most likely a very awaited one: How does one inject a micro-frontend's CSS into the page that loads it? By the way, note that this question is not **Vite**\-specific, but because this series is about working with all-**Vite** projects, we will explore the topic around what **Vite** can provide.

Let's start where we left off in the previous article, shall we?

## Modifying the mySspaRoot Project

I don't know if you noticed, but during our exercise in the previous article, we saw a decently styled React micro-frontend coming up in our root project's HTML page while said micro-frontend was being served (`npm run dev`). This might be a coincidence: Since both projects are `Vite + <Technology>` projects, they are very similarly styled, including CSS class names.

In order to make the CSS effort evident, we'll modify the root project enough to break this similarity, at least enough to makes us realize if some styling was a coincidence or not. This will help us see the extent of the effort we need to take into making CSS work for us.

First and foremost, I changed the page styles by simplifying `src/style.css` significantly. I also removed the button styling because (as seen later) I removed the counter button from this root project. Enough said. This is how my `src/style.css` file looks like now:

```css
:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color: black;
  background-color: #ffffff;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

h1 {
  font-size: 3.2em;
  line-height: 1.1;
}

#app {
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
}
```

Most fancy styling that I don't even know about was removed; maximum document width was taken out as well (I'm using 4K monitors, so...).

The `HelloWorld` Vue component was deleted entirely (it contained the counter button), and the contents of `src/App.vue` were changed to this:

```typescript
<script setup lang="ts">
import vueLogo from './assets/vue.svg';
import Link from './components/Link.vue';
</script>

<template>
  <main>
    <section>
      <img src="/vite.svg" class="logo" alt="Vite logo" />
      <h1><img :src="vueLogo" alt="Vue" class="vue" /> Root Project</h1>
      <h4>vite-plugin-single-spa</h4>
      <div class="content">
        <p>Welcome to the development of <em>vite-plugin-single-spa</em>. This is content provided by the root project and
          comes from the <em>App</em> Vue component.</p>
        <Link url="/mifea">Load Microfrontend A</Link>
      </div>
    </section>
    <section id="single-spa-application:mifeA"></section>
  </main>
</template>

<style scoped>
main {
  display: flex;
  place-items: left;
  gap: 2em;
}

main>section {
  max-width: 50%;
  border: rgb(48, 48, 124) 0.25em dashed;
  border-radius: 0.7em;
  padding: 1em;
}

main>section:first-child {
  border: none;
  color: white;
  background-color: rgb(48, 48, 124);
}

main>section:first-child a,
main>section:first-child a:visited {
  color: yellowgreen;
}

h1+h4,
h1 {
  margin: 0;
  padding: 0;
}

div.content {
  text-align: justify;
}

p em {
  color: yellow;
  font-weight: bold;
}

.logo {
  height: 10em;
  padding: 1.5em;
  will-change: filter;
  transition: filter 300ms;
}

.logo:hover {
  filter: drop-shadow(0 0 2em #ba39feaa);
}

.vue {
  height: 1em;
}
</style>
```

The net result:


![Modified Root Project](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u62erlheu761oa0h2uy0.png)

One more detail: The hyperlink at the bottom that triggers loading the micro-frontend is a component named `Link` that I created very quickly using `single-spa`'s `navigateToUrl()` function. This is `components/Link.vue`:

```typescript
<script setup lang="ts">
import { navigateToUrl } from 'single-spa';

defineProps<{
    url: string
}>();
</script>

<template>
    <a href="{{ url }}" @click.prevent="navigateToUrl(url)">
        <slot>
        </slot>
    </a>
</template>
```

With these modifications, we can start essaying solutions.

## Initial Testing

As per usual, we will test with `vite serve` (`npm run dev`) and `vite preview` (`npm run build` + `npm run preview`). We will determine what work is needed for each command in the CSS arena.

Let's start with `vite serve`. Run the root project using `npm run dev`, and then run the React micro-frontend, also using `npm run dev`. Now click the hyperlink to load micro-frontend A. This is what you probably see:


![Root Project with  Microfrontend A Loaded in Serve Mode](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/75i6vb05shfqwmdf99re.png)

Let's compare it to the standalone version of the react micro-frontend. Load **http://localhost:4101**. Remember that our projects behave dually while in serve mode, so we don't have to do anything other than enjoy this duality. This is what you should see:

![Microfrontend A in Standalone Mode](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gualcfq7x3g9s61q6ib4.png)

Now, let's compare:

1. The React logo is missing. This is no surprise: Until the **Vite** development team agrees to modify how **Vite** handles the `base` property, we won't have assets showing up in our root project while in serve mode.
    
2. The button is not styled.
    
> If you are observant, I intentionally changed the hover color of the Vite logo in the root project to see if the Vite logo in the React side inherited it. This did not happen.

Pretty good, I would say! Now let's repeat with the micro-frontend being previewed. Stop the **myReactMife** project's server, then run `npm run build` and then `npm run preview`. The root project will lose the React part (due to an incorrect import map as seen in the previous article of the series), so to quickly see it again, do the same with the root project's server (preview it). This is the visual result:

![Root Project with Microfrontend A Loaded in Preview Mode](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i0xgz3fvlk048zrrj82t.png)

Compare, once more:

1. The React logo is back. Expected, since we are now previewing, not serving in development mode.
    
2. The React logo is not rotating.
    
3. The logos don't have the expected size.
    
4. The bottom paragraph is written in black, not gray.
    
5. The spacing between elements changed.
    
6. The logos in the React side don't have a hover effect.
    
7. The button is still not styled.
    

The conclusion that we draw from this exercise: During `vite serve` we get CSS injected, but once the project is built, no CSS is injected for us.

> We can also conclude that it is best to avoid generic class names or styles for HTML elements in the projects that can unintentionally modify other projects' components and HTML elements. As a rule of thumb, try to always style scoped and minimize the use of general CSS files.

### Optional: Peeking Inside the Page

In case you want more validation, open the developer tools and look at the HTML node tree. Locate and expand the `head` element. You'll see this:

```xml
<style type="text/css" data-vite-dev-id="C:/Users/webJo/src/myReactMife/src/App.css">#root {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
}

.logo {
  height: 6em;
  padding: 1.5em;
  will-change: filter;
  transition: filter 300ms;
}
.logo:hover {
  filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
  filter: drop-shadow(0 0 2em #61dafbaa);
}

@keyframes logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

@media (prefers-reduced-motion: no-preference) {
  a:nth-of-type(2) .logo {
    animation: logo-spin infinite 20s linear;
  }
}

.card {
  padding: 2em;
}

.read-the-docs {
  color: #888;
}
</style>
```

**Vite** is working its magic: It creates code that inserts the styles consumed by components in the form of `style` elements. What you see there is the the exact contents of `src/App.css` of the **myReactMife** project, inside the webpage produced by the **mySspaRoot** project.

---

Now that we have identified the problem fully, let's work on the solution.

## Mounting and Unmounting CSS

Hopefully, you are familiar with the `single-spa` documentation. In [said documentation](https://single-spa.js.org/docs/ecosystem-css/), we can see a lot of information. There are framework-specific tools, descriptions of processes to follow and even packages like the `single-spa-css` NPM package.

Since I would like to keep my investigation as neutral as possible in terms of framework, I have decided that `single-spa-css` is the best choice. But oh-oh! This is a plug-in that only plays nice with **webpack**, and we use **Vite** which is powered by **rollup**.

Still, this is something that shouldn't be too hard to do on our own. Let's program lifecycle functions that mount and unmount CSS!

### CSS Lifecycle Functions

We need to modify the file `src/spa.tsx` in the **myReactMife** project to return lifecycle functions for the CSS. Allow me to show you the end result, since it is quite straightforward:

```typescript
import React from 'react';
import ReactDOMClient from 'react-dom/client';
// @ts-ignore
import singleSpaReact from 'single-spa-react';
import App from './App';

let linkEl: HTMLLinkElement;

function bootstrapCss() {
    linkEl = globalThis.document.createElement('link');
    linkEl.rel = 'stylesheet';
    linkEl.href = import.meta.env.BASE_URL + '/assets/spa.css';
    return Promise.resolve();
}

function mountCss() {
    globalThis.document.head.appendChild(linkEl);
    return Promise.resolve();
}

function unmountCss() {
    globalThis.document.head.removeChild(linkEl);
    return Promise.resolve();
}

const lc = singleSpaReact({
    React,
    ReactDOMClient,
    rootComponent: App,
    errorBoundary(err: any, _info: any, _props: any) {
        return <div>Error: {err}</div>
    }
});

export const bootstrap = [bootstrapCss, lc.bootstrap];
export const mount = [mountCss, lc.mount];
export const unmount = [unmountCss, lc.unmount];
```

Super easy task. Let's review this. We start by declaring the `linkEl` variable that will hold the reference to the link element we will insert in the document's `head` element. Then we proceed to create `bootstrap`, `mount` and `unmount` functions that initialize, add and remove the link element to and from the page's `head` element.

While the functions are quite simple and straightforward, let's highlight a couple of important points here:

* The link's URL. It is constructed by concatenating the base URL (**Vite**'s `base` property) and the resulting asset file name. See a screenshot of the output of `npm run build` below. We simply append what's showing there, in magenta color.

![List of Files Created During Build](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ppyip9cnyx9jbt0jjr8n.png)

* `single-spa` expects the lifecycle functions to return promises, so all the lifecycle functions return a resolved promise.
    

Ok, so let's re-build the project and preview it once more. Voilá! Rotating React logo, spacing, colors, all back. But wait, the button styling is still wrong. What happened? If you recall our comparison at the beginning of this article, this is an issue while serving and previewing.

Well, the button styles are in the project's `src/index.css` file, and said file is never imported in any of the bundled JavaScript/TypeScript modules. This styling existed in the root project before I made the styling changes to it. If you recall from the previous article, the button was showing proper styling back then. We were right: Because both projects are so similar, we had unintentional/coincidental CSS styling across projects.

I don't know if you know about assets, so here it is: **Assets** (the files that end up in the assets folder on build) are only assets if they are imported. The import statements in the JS/TS modules is what tells **Vite** to process them. If something is not imported, **Vite** doesn't pick it up, doesn't bundle it, doesn't parse it for asset URL replacing, etc. So the lesson here is: Always put all micro-frontend styles in imported CSS files.

To quickly see the difference, add the statement `import './index.css'` to `src/App.tsx`, rebuild and restart previewing. You'll see that the size of `assets/spa.css` increases, and if you refresh the root project's home page, you'll see that the button's style is now applied.

> Whether or not you keep a general stylesheet in your micro-frontend is up to you. Just remember that it needs to be imported somewhere for **Vite** to pick it up. It can't just exist or just be referenced in the micro-frontend's index page because the index page is ignored when building the project. If you were to ask me, I would have a general stylesheet if I had to style the same in several places/components. Otherwise, I would stay in the realm of scoped (per-component) CSS.

---

Ok, so it seems that we are in the business of making great user interfaces with `single-spa`. Let's go back to serving the projects (not previewing). Stop both **Vite** servers (**mySspaRoot** and **myReactMife**) and restart serving (`npm run dev`). We want to make sure we haven't broken anything in serve mode. Refresh the project root's page. What do you see? All but the React logo, right? All seems fine, including the rotating logo and the button's style. Everything is ok... or is it? Open the browser's console. Boom! You'll find this:

```plaintext
Failed to load resource: the server responded with a status of 404 (Not Found)  react.svg:1
Failed to load resource: net::ERR_NAME_NOT_RESOLVED  spa.css:1
```

The first one is expected: We know that, while in serve mode, we cannot get assets, but the second one is new.

Examine the page's source and locate the end of the contents of the `head` element to find this:

```xml
<link rel="stylesheet" href="//assets/spa.css">
```

We are generating and invalid URL for the stylesheet because we built a URL that starts with two slashes. Let's quickly correct that in `src/spa.tsx`:

```typescript
const base = import.meta.env.BASE_URL;
linkEl.href = base + (base.endsWith('/') ? '' : '/') + 'assets/spa.css';
```

Refresh the root's page in the browser and open the console. We now have a different error:

```plaintext
GET http://localhost:4100/assets/spa.css net::ERR_ABORTED 404 (Not Found)  spa.tsx:20
```

This is in response to the inserted link element in head:

```xml
<link rel="stylesheet" href="/assets/spa.css">
```

The URL we have constructed, while now without the double slash problem, still points to an incorrect location. The server that has the stylesheet is not the one in port 4100. The correct port is 4101. Furthermore, during serve there is no bundle, so `spa.css` doesn't really exist while serving.

This happens because **Vite**, while in serve mode, reduces full URL `base` values to its path. It is a manifestation of a problem we have already fully identified.

> Promote a change! Visit [this GitHub discussion](https://github.com/vitejs/vite/discussions/13927) and upvote it if you would like to see Vite serving assets while in serve mode.

While the presence of this error is not a big deal, I would like to explore the possibility of getting rid of this message.

## Enhancing CSS Mounting

The problem described just above happens because `src/spa.tsx` blindly tries to mount CSS. This mounting is actually unnecessary when **Vite** runs in serve mode. We have seen early in this article how **Vite**'s magic inserts all styling for us. The solution then, is to only mount CSS whenever **Vite** is not running in serve mode. We don't need this work while in serve mode.

**Vite** is a bundling tool, primarily. It is actually not present in the majority of cases after it builds the project. The solution then, must persist through the building process and must not be dependant on the existence of **Vite**.

Ideally, I would like to code a simple guard like this:

```typescript
function bootstrapCss() {
    if (vite.serve) {
        return Promise.resolve();
    }
    // The rest here.
}
```

The problem's solution is then reduced to find a way to define `vite.serve`.

As it turns out, this is really simple to accomplish. **Vite** has the ability to define constants using its `define` section in the configuration. All we need to do is define this to be `true` or `false` depending on the value of the current **Vite** command.

Let's tweak `Vite`'s configuration once more, using something we have used before: We will return a function that returns the configuration object as opposed to returning the configuration object directly.

This is how `vite.config.ts` should look like after our modification:

```typescript
import { ConfigEnv, defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import vitePluginSingleSpa from 'vite-plugin-single-spa';

// https://vitejs.dev/config/
export default function (opts: ConfigEnv) {
  return defineConfig({
    plugins: [react(), vitePluginSingleSpa(
      {
        type: 'mife',
        serverPort: 4101,
        spaEntryPoint: 'src/spa.tsx'
      }
    )],
    server: {
      hmr: false
    },
    define: {
      '__vite_serve__': opts.command === 'serve' ? 'true' : 'false'
    }
  });
};
```

Now we are exporting a function that receives **Vite**'s configuration environment as first argument. Said object contains the `command` property that tells us if **Vite** is running in serve or build mode.

We proceed to use the `command` property in the definition of the constant we are creating, named `__vite_serve__`.

Now the problem is solved! All we need to do is make use of this constant. This is how `src/spa.tsx` ends up looking like:

```typescript
import React from 'react';
import ReactDOMClient from 'react-dom/client';
// @ts-ignore
import singleSpaReact from 'single-spa-react';
import App from './App';

let linkEl: HTMLLinkElement;

const noCss = () => Promise.resolve();

function bootstrapCss() {
    linkEl = globalThis.document.createElement('link');
    linkEl.rel = 'stylesheet';
    const base = import.meta.env.BASE_URL;
    linkEl.href = base + (base.endsWith('/') ? '' : '/') + 'assets/spa.css';
    return Promise.resolve();
}

function mountCss() {
    globalThis.document.head.appendChild(linkEl);
    return Promise.resolve();
}

function unmountCss() {
    globalThis.document.head.removeChild(linkEl);
    return Promise.resolve();
}

const lc = singleSpaReact({
    React,
    ReactDOMClient,
    rootComponent: App,
    errorBoundary(err: any, _info: any, _props: any) {
        return <div>Error: {err}</div>
    }
});

export const bootstrap = [__vite_serve__ ? noCss : bootstrapCss, lc.bootstrap];
export const mount = [__vite_serve__ ? noCss : mountCss, lc.mount];
export const unmount = [__vite_serve__ ? noCss : unmountCss, lc.unmount];
```

As you can see, I found it to be more succint to make use of the `__vite_serve__` constant at the bottom of the module in a ternary conditional operator.

**NOTE**: For TypeScript projects like the one we are using here, we must do one more thing: We need to inform TypeScript about this constant. We do this by creating the file `src/env.d.ts` and adding one line:

```typescript
declare const __vite_serve__: boolean;
```

---

We have reached the end of exercise. We now know how to inject CSS and when while working with **Vite** projects and `single-spa`.

At this point in the series, I have successfully delivered the core promises:

* **Vite** projects that can be used simultaneously as standalone or `single-spa` micro-frontends.
    
* **Vite** projects that can serve as root `single-spa` projects.
    
* **Vite** + `single-spa` projects that can properly serve assets, at least after being built.
    
* **Vite** + `single-spa` projects that load CSS while in serve or build modes.
    
* **Vite** root projects that utilize import maps and the `import-map-overrides` NPM package.

With what we have learned up to this point, we can start messing around with bigger, more complex and varied projects, and we can look for opportunities to improve the new `vite-plugin-single-spa` plug-in. We will be vigilant of new challenges as we progress with the experimentation.

Today's learnings certainly pose an interesting question: Can CSS injection be handled inside `vite-plugin-single-spa`? After all, the plug-in can get the CSS file name (or names), base URL and current **Vite** command (`serve` or `build`). It feels like the plug-in could at least create the CSS lifecycle functions in a separate (dynamically-created) module. This sounds more like a rollup task. I'll investigate to see if this is possible and will keep you all informed.

In the meantime, enjoy your **Vite**, `single-spa`\-enabled projects. Happy coding!