Injecting Micro-Frontend CSS in single-spa

Injecting Micro-Frontend CSS in single-spa

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:

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

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

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:

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

Let's compare it to the standalone version of the react micro-frontend. Load 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:

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:

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:

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

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.

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

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:

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

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:

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:

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

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:

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:

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:

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!