DEV Community

Cover image for Single-SPA Parcels and vite-plugin-single-spa
José Pablo Ramírez Vargas
José Pablo Ramírez Vargas

Posted on • Updated on • Originally published at webjose.hashnode.dev

Single-SPA Parcels and vite-plugin-single-spa

UPDATE: vite-plugin-single-spa v0.4.0 is out and is compatible with parcels.

Welcome, everyone, to the next topic in the single-spa arena: Parcels. These are pieces of user interfaces that are meant to be used as "utilities" from anywhere in your application, and since your application is built by glueing together mini applications (micro-frontends), it means that parcels are meant to be used by any micro-frontend, regardless of the parcel framework or the micro-frontend framework.

What This Article is Not About

I apologize if I made you believe I was going to give a tutorial on single-spa parcels. I will not. However, that doesn't necessarily mean that this article won't have explanations around how to create them. It will. It is just that the article's objective is not to be a tutorial on single-spa parcels.

Instead, this article will talk about parcels in the context of the vite-plugin-single-spa plug-in and the general requirements to make projects that export them, from start to finish.

Setting Up Test Projects

Ok, since my interest in this topic involves "old" React code and new Svelte code, I'll be focusing on this combination.

Let's create a React root project and a Svelte parcels project, which would be a micro-frontend type project.

The root project:

PS C:\Users\webJo\src> npm create vite@latest
√ Project name: ... ReactRoot
√ Package name: ... reactroot
√ Select a framework: » React
√ Select a variant: » TypeScript + SWC

Scaffolding project in C:\Users\webJo\src\ReactRoot...

Done. Now run:

  cd ReactRoot
  npm install
  npm run dev
Enter fullscreen mode Exit fullscreen mode

And then the parcels project:

PS C:\Users\webJo\src> npm create vite@latest
√ Project name: ... SvelteParcels
√ Package name: ... svelteparcels
√ Select a framework: » Svelte
√ Select a variant: » TypeScript

Scaffolding project in C:\Users\webJo\src\SvelteParcels...

Done. Now run:

  cd SvelteParcels
  npm install
  npm run dev
Enter fullscreen mode Exit fullscreen mode

Great! In the root project, let's install the needful:

npm i vite-plugin-single-spa bootstrap single-spa single-spa-react
npm i -D sass
Enter fullscreen mode Exit fullscreen mode

Let's do similarly in the parcels project:

npm i vite-plugin-single-spa single-spa-svelte
npm i -D bootstrap sass
Enter fullscreen mode Exit fullscreen mode

Excellent. We can start.

Root Project Code

First, delete App.css and index.css and instead add src/App.scss. Remove all references to the deleted files (use the Find in Files functionality of your code editor). Then add import './app.scss'; to src/App.tsx.

Now create appropriate styling and new content for the App component.

This is src/App.scss:

@import "../node_modules/bootstrap/scss/bootstrap";

html {
    --main-color: rgb(73, 13, 130);
}

div.app {
    height: 100vh;
    display: flex;
    align-items: center;
    width: 100%;
}

.content {
    border-radius: 0.5em;
    padding: 1.5em;
    height: 100%;
}

.root-content {
    @extend .content;
    background-color: var(--main-color);
    color: white;

    & > h1 > span {
        margin-right: 0.3em;
    }
}

.parcel-content {
    @extend .content;
    border: 0.25em dashed (var(--main-color));
}
Enter fullscreen mode Exit fullscreen mode

This is src/App.tsx:

import { useState } from 'react'
import reactLogo from './assets/react.svg'
// @ts-expect-error
import Parcel from 'single-spa-react/parcel'
import './App.scss'

function App() {
  const [loadParcel, setLoadParcel] = useState(false);

  return (
    <div className="app">
      <div className="row w-100">
        <div className="col-sm-5">
          <div className="root-content">
            <h1><span><img src={reactLogo} alt="React" /></span>React Root Project</h1>
            <p>
              Click the button below to load a Svelte parcel.
            </p>
            <button type="button" className="btn btn-primary">Toggle Parcel</button>
          </div>
        </div>
        <div className="col-sm-7">
          <div className="parcel-content">
            <h3>Parcel Display</h3>
            {loadParcel ? <Parcel /> : null}
          </div>
        </div>
      </div>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

You should now see a root project similar to this:

Root project appearance.

Looking good so far. Let's stop for a moment here with the root project because now we need to know about our parcel, and that's the other (Svelte-powered) project.

Parcel Project Code

We start cleaning up. Delete the src/App.css file, and remove its reference in src/main.ts. Theoretically speaking, we should just be able to create the parcel component(s) we want without worrying about the index page or the src/main.ts script. However, if you would like to conserve the ability to see what you will mount in the root project without mounting in the root project, we can set something up for sure.

Since we are using Bootstrap here, let's create src/App.scss to get ourselves a copy of Bootstrap ready to go in the test page:

@import "../node_modules/bootstrap/scss/bootstrap";
Enter fullscreen mode Exit fullscreen mode

Then add import './App.scss'; to src/main.ts. That should be it.

Now, unlike "regular" micro-frontends, we won't be exporting single-spa lifecycle functions that mount the App Svelte component. No sir! We will now mount potentially multiple Svelte components, each requiring single-spa lifecycle functions.

So src/App.svelte is only a concern when it comes to testing without mounting. Let's create a component that we can mount as a parcel now.

Add the file src/lib/Welcome.svelte:

<script lang="ts">
    export let name: string | undefined = undefined;
</script>

<div>
    <p>Welcome, <span class="text-primary">{name ? name : 'person or thing'}!</span></p>
    <button class="btn btn-secondary">{name ? 'Sign out' : 'Sign in'}</button>
</div>
Enter fullscreen mode Exit fullscreen mode

This is a very simple component that welcomes the user. It has a single prop named name, and if no name is provided, we assume the user hasn't logged in. All styling is done through Bootstrap classes.

To see how it looks like in the test page, simply reduce src/App.svelte to this:

<script lang="ts">
    import Welcome from './lib/Welcome.svelte';
</script>

<main>
  <Welcome />
</main>
Enter fullscreen mode Exit fullscreen mode

This should show you this:

Welcome Svelte parcel.

I intentionally added part of Microsoft Edge's border and toolbar to remind us all how lacking is our project right now in terms of styling that our content is completely against the browser's edges. However, let's remind ourselves that we want mountable parcels, so this should not be a primary concern for us at this point in time.


Ok, let's call the initial setup of both projects complete at this point. We shall now proceed to export the Svelte-powered single-spa parcel.

Exporting Parcels

According to single-spa's documentation, we export a parcel very similarly to how we export micro-frontends. We are even encouraged to use the same helpers (single-spa-react, single-spa-svelte, etc.).

Add the file src/parcels.ts to the SvelteParcels project:

import Welcome from "./lib/Welcome.svelte";
// @ts-expect-error
import singleSpaSvelte from 'single-spa-svelte';

export const welcomeParcel = singleSpaSvelte({
    component: Welcome
});
Enter fullscreen mode Exit fullscreen mode

Modify vite.config.ts:

import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import vitePluginSingleSpa from 'vite-plugin-single-spa'

export default defineConfig({
  plugins: [svelte(), vitePluginSingleSpa({
    serverPort: 4201,
    spaEntryPoint: 'src/parcels.ts'
  })],
});
Enter fullscreen mode Exit fullscreen mode

Note: This changes our Vite server port! If your test page stopped working, note the new port value.

At this point, I am confident we successfully exported the Welcome Svelte component as a single-spa parcel. Let's go back to the root project.

Importing Parcels

Let's now take care of the configuration of the root project. We haven't added any of the single-spa things that we need.

Start by adding src/importMap.json:

{
    "imports": {
        "@test/parcels": "http://localhost:4201/spa.js"
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, src/importMap.dev.json:

{
    "imports": {
        "@test/parcels": "http://localhost:4201/src/parcels.ts"
    }
}
Enter fullscreen mode Exit fullscreen mode

These should be familiar to you: It is almost identical to what we set up for micro-frontends.

Now, modify vite.config.ts:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import vitePluginSingleSpa from 'vite-plugin-single-spa'

export default defineConfig({
  plugins: [react(), vitePluginSingleSpa({
    type: 'root',
    imo: '3.1.0'
  })],
})
Enter fullscreen mode Exit fullscreen mode

So far, so good. Now to src/App.tsx to make the "Toggle Parcel" button work.

In case you didn't realize, we installed single-spa-react in the root project. Why? Because it comes with a React component called Parcel that can be used to easily mount single-spa parcels. We even added it to the markup very early on. Its documentation tells us that we must provide the config prop at the very least. Its value must be a parcelConfig object, or a loader function that returns said object asynchronously. Let's use the latter as it seems simpler because we won't have to work magic around the statement import { welcomeParcel } from '@test/parcels'; when running in development mode.

Ok, allow me to show the code for src/App.tsx with the modifications:

import { useState } from 'react'
import reactLogo from './assets/react.svg'
// @ts-expect-error
import Parcel from 'single-spa-react/parcel'
import './App.scss'

function App() {
  const [loadParcel, setLoadParcel] = useState(false);
  const parcelModuleName = "@test/parcels";

  async function loadWelcomeParcel() {
    const parcelsModule = await import(/* @vite-ignore */ parcelModuleName);
    return parcelsModule.welcomeParcel;
  }

  return (
    <div className="app">
      <div className="row w-100">
        <div className="col-sm-5">
          <div className="root-content">
            <h1><span><img src={reactLogo} alt="React" /></span>React Root Project</h1>
            <p>
              Click the button below to load a Svelte parcel.
            </p>
            <button
              type="button"
              className="btn btn-primary"
              onClick={() => setLoadParcel(v => !v)}
            >
              Toggle Parcel
            </button>
          </div>
        </div>
        <div className="col-sm-7">
          <div className="parcel-content">
            <h3>Parcel Display</h3>
            {loadParcel ? <Parcel
              config={loadWelcomeParcel}
              handleError={console.error}
              parcelDidMount={() => console.debug('Parcel mounted.')}
            /> : null}
          </div>
        </div>
      </div>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

The modifications are rather minimal:

  • We defined the module name in a constant for two reasons: The first one is that Vite will complain if we put it directly inside the dynamic import() call; the second one is that we can use it in other loader functions as we add parcels to our parcels project. I don't know if I will do it, but I thought I might.

  • We defined the loadWelcomeParcel() function that imports and returns the named welcomeParcel export.

  • We provided functionality to the "Toggle Parcel" button we had.

  • We gave the Parcel component the needed properties, which are:

    • config: The loader function we created.
    • handleError: We added the console.error method as our error handler.
    • parcelDidMount: We added a simple logging statement for good measure.

Great!, save, click the "Toogle Parcel" button and .... doesn't work! Nothing happens. Well, it took me some time to figure this one out. The issue has been raised as a bug since Jaunuary 13, 2023, here. I'm no React developer, so I don't know if what I'm about to say is bad: Solve this by removing <React.StrictMode> from src/main.tsx. If this is a bad thing, please, by all means don't take it against me! Go to the issue's page and bombard it with comments and upvotes so it gets fixed. Even better: If you can fix it, fix it and make a pull request for it!

Ok, back to topic. This is how I have src/main.tsx after removing <React.StrictMode>:

import ReactDOM from 'react-dom/client'
import App from './App.tsx'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <App />
)
Enter fullscreen mode Exit fullscreen mode

Just the App component. Ok! Let's retry! Another error, that we see twice (once for our error handler, and once for the uncaught nature of the error):

Error: During 'mount', parcel threw an error: was not passed a mountParcel prop, nor is it rendered where mountParcel is within the React context. If you are using within a module that is not a single-spa application, you will need to import mountRootParcel from single-spa and pass it into as a mountParcel prop

This one can be understood easily: The Parcel component looks for the mountParcel function inside the SingleSpaContext context that single-spa-react provides to micro-frontends. However, since I'm a stubborn child that doesn't follow single-spa's recommendations, I have a root React project that wants to load a parcel. So how do I fix? Very simply: We use single-spa's mountRootParcel() function. Just import this function and add it to the Parcel component as a prop:

// This is src/App.tsx, just in case.

// Other imports here...
import { mountRootParcel } from 'single-spa'
// Rest of code here, up to the Parcel component...

            {loadParcel ? <Parcel
              config={loadWelcomeParcel}
              mountParcel={mountRootParcel}
              handleError={console.error}
              parcelDidMount={() => console.debug('Parcel mounted.')}
            /> : null}
// Rest of file here...
Enter fullscreen mode Exit fullscreen mode

Save and try again. Success! Kind of. Something unexpected is in sight. Let me show you a screenshot:

Svelte parcel inside the root project's page with an unexpected name.

Did you see? The name! It says "Welcome, parcel-0"! I was expecting "Welcome, person or thing!" which is what I programmed for the cases where the name prop was not specified. Furthermore, the button's caption is "Sign out", not "Sign in". One more learning, I suppose: One of the properties injected by single-spa to parcels is the name property, and its value is an automatically-assigned name. We need to avoid using name as a component prop, unless we are after the parcel's name.

Now that we are speaking about single-spa props sent to parcels, let's complete the list easily by looking at the warnings in the console:

Warnings about unexpected component props.

This means that the complete list of props a parcel receives is:

Name Description
name The parcel's name.
domElement The element where the parcel is mounted.
mountParcel A function that can be used to mount parcels. At this point in time, I am unsure if this is mountRootParcel, or something else.
singleSpa The singleSpa instance object; same as in micro-frontends.
unmountSelf A function that unmounts the parcel, made available to the parcel.

This list of properties does not seem to be anywhere in the single-spa's official documentation website, so you might want to bookmark this article.

Excellent progress! We have managed to export parcels from Svelte and use them in React. Let's try to interact with the parcel from React.

Interacting With Parcels

Due to our unexpected outcome, let's first rename the name prop in the Welcome parcel to user:

<script lang="ts">
    export let user: string | undefined = undefined;
</script>

<div>
    <p>Welcome, <span class="text-primary">{user ? user : 'person or thing'}!</span></p>
    <button class="btn btn-secondary">{user ? 'Sign out' : 'Sign in'}</button>
</div>
Enter fullscreen mode Exit fullscreen mode

Because of HMR, saving this immediately shows up correctly inside the React page.

Ok, let's add a text box for us to type a user from React and have React send this to the Welcome parcel as the user's name.

  // This is in src/App.tsx:
  // One more ugly state for the user:
  const [user, setUser] = useState<string | undefined>(undefined);

  // Then the modification in markup, including passing the prop to the parcel.
  <h3>Parcel Display</h3>
  <div className="mb-3">
    <label htmlFor="user">User's name:</label>
    <input className="form-control" type="text" id="user" onInput={(e) => setUser(e.currentTarget.value)} />
  </div>
  {loadParcel ? <Parcel
    config={loadWelcomeParcel}
    mountParcel={mountRootParcel}
    handleError={console.error}
    parcelDidMount={() => console.debug('Parcel mounted.')}
    user={user}
  /> : null}
Enter fullscreen mode Exit fullscreen mode

Save and test:

React component updating Svelte parcel's properties.

Aw, stop! You're too kind. But, yes, yes I am.

In all seriousness now: The coupling works seamlessly, where the Svelte component quickly updates its state on every keystroke. There is no functional difference between this parcel and a component made in React in the same root project.

Let's now do the inverse: Have the Svelte parcel interact with the React code. For this, we will add a handler to the click event of the button in the parcel.

Because React doesn't work with events, the simplest is to provide a prop for a callback in the Svelte side. Add a new prop to the Welcome component, then add a handler function, and finally, inside the handler, both dispatch the event and call the callback prop:

// Import the usual createEventDispatcher and create the dispatch function:
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
...
// The new prop:
export let onSignInOut: Function | undefined = undefined;
...
// The handler:
function signInOutHandler() {
    const detail = {
        user,
        signedIn: !!user
    };
    dispatch('signInOut', detail);
    (onSignInOut ?? (() => {}))(detail);
}
Enter fullscreen mode Exit fullscreen mode

Finally, modify the button so it runs the handler on click:

<button class="btn btn-secondary ms-auto" on:click={signInOutHandler}>{user ? 'Sign out' : 'Sign in'}</button>
Enter fullscreen mode Exit fullscreen mode

Now to the ReactRoot project, where we add the extra prop to the Parcel component in src/App.tsx:

{loadParcel ? <Parcel
  config={loadWelcomeParcel}
  mountParcel={mountRootParcel}
  handleError={console.error}
  user={user}
  onSignInOut={(e: any) => console.debug('Sign In/Out: %o', e)}
/> : null}
Enter fullscreen mode Exit fullscreen mode

Test this by loading the parcel with the "Toggle Parcel" button, then clicking the "Sign In" button. The console should show you the data received in the call back. Very simple and straightforward.

NOTE: We did the event and the callback in Svelte for maximum compatibility. Maybe Svelte v5 surprises us with bubbling Svelte events in the future. If that's the case, maybe we can switch to consuming the event in a parent element of the Parcel component. We'll see.

We have covered the basic functionality of parcels with great success so far, but one important topic remains: The parcel's CSS.

CSS in Parcels

So far, our parcel has only had Bootstrap CSS classes applied, and since our root project provides Bootstrap, we haven't noticed any problems in the styling department. This demonstrates how useful a shared styleguide is when working with micro-frontends. But let's be honest: Real-life applications will more than likely need more CSS. So let's explore what happens in our current set up when we add styling to the Svelte component.

Before we start, go ahead and build the SvelteParcels project. You'll see its output consists of a single file: spa.js:

vite v4.5.0 building for production...
✓ 27 modules transformed.
dist/spa.js  6.65 kB │ gzip: 2.81 kB
✓ built in 299ms
Enter fullscreen mode Exit fullscreen mode

There is no CSS needed for injection. This parcel works out of the box without us having to worry about CSS files.

So, back to topic: Let's pretend for a moment that Bootstrap doesn't provide utility classes, or that I simply don't know that I can do class="d-flex flex-nowrap align-items-baseline gap-3 w-100". So let's make the Svelte component line things up in one line.

Modify src/lib/Welcome.svelte like below:

<script lang="ts">
    export let user: string | undefined = undefined;
</script>

<div class="welcome">
    <p>Welcome, <span class="text-primary">{user ? user : 'person or thing'}!</span></p>
    <button class="btn btn-secondary ms-auto">{user ? 'Sign out' : 'Sign in'}</button>
</div>

<style>
    div.welcome {
        display: flex;
        flex-flow: row nowrap;
        gap: 1em;
        align-items: baseline;
        width: 100%;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

The only things that changed are that we applied a CSS class to the component's root DIV element, that we added automatic margin to the button to push it to the right, and of course, the main thing here: The style tag.

As soon as you save, the changes can be seen in the SvelteParcels test page and the ReactRoot page. This happens because Vite dev server injects the CSS for us. If you don't believe, just examine the contents of the HEAD HTML element. The last element will be:

<style type="text/css" data-vite-dev-id="C:/Users/webJo/src/SvelteParcels/src/lib/Welcome.svelte?svelte&amp;type=style&amp;lang.css">div.welcome.s-U5UaycqR-l4Y{display:flex;flex-flow:row nowrap;gap:1em;align-items:baseline;width:100%}.s-U5UaycqR-l4Y{}</style>
Enter fullscreen mode Exit fullscreen mode

This luxury is gone in a built project, so we can expect to lose our gains if we were to run the projects in preview mode. Let's.

This is my build of SvelteParcels:

vite v4.5.0 building for production...
✓ 28 modules transformed.
dist/assets/vpss(svelteparcels)parcels-2d7e02d5.css  0.10 kB │ gzip: 0.11 kB
dist/spa.js                                          6.69 kB │ gzip: 2.83 kB
✓ built in 300ms
Enter fullscreen mode Exit fullscreen mode

This is the build for ReactRoot, although of no particular importance to the topic at hand:

vite v4.5.0 building for production...
✓ 36 modules transformed.
dist/index.html                   0.90 kB │ gzip:  0.50 kB
dist/assets/react-35ef61ed.svg    4.13 kB │ gzip:  2.14 kB
dist/assets/index-37125051.css  226.14 kB │ gzip: 30.93 kB
dist/assets/index-c8b3e608.js   169.48 kB │ gzip: 54.44 kB
✓ built in 2.94s
Enter fullscreen mode Exit fullscreen mode

Unrelated note: Amazing how heavy React is. Svelte, I shall be with you until my final days!

Now do npm run preview on both projects, then open ReactRoot's page. Sure enough, the scoped styling in the Svelte component is gone.

Using cssLifecycle from vite-plugin-single-spa

We solved the CSS mounting and unmounting of micro-frontends some time ago with a dynamic ES module called vite-plugin-single-spa/ex that provides the cssLifecycle object. This is used when exporting the single-spa lifecycle functions, and we can give it a go right now.

Open src/parcels.ts in the SvelteParcels project and modify it to look like this:

import Welcome from "./lib/Welcome.svelte";
// @ts-expect-error
import singleSpaSvelte from 'single-spa-svelte';
import { cssLifecycle } from 'vite-plugin-single-spa/ex';

const lc = singleSpaSvelte({
    component: Welcome
});

export const welcomeParcel = {
    bootstrap: [cssLifecycle.bootstrap, lc.bootstrap],
    mount: [cssLifecycle.mount, lc.mount],
    unmount: [cssLifecycle.unmount, lc.unmount],
    update: lc.update
};
Enter fullscreen mode Exit fullscreen mode

Now build SvelteParcels one more time, but remember to add Vite's base property to vite.config.ts first, so the Link HTML element wil contain the correct URL, like this:

export default defineConfig({
  plugins: [svelte(), vitePluginSingleSpa({
    serverPort: 4201,
    spaEntryPoint: 'src/parcels.ts'
  })],
  base: 'http://localhost:4201' // <----  THIS!!!!
});
Enter fullscreen mode Exit fullscreen mode

Build one more time, then refresh the ReactRoot's webpage. Now the style is in.

Although it might seem that we have all figured out, in reality we are far from correctness. To demonstrate this easily, let's duplicate the user text box and the parcel in the ReactRoot project, so we can see two instances of the Welcome parcel simultaneously.


Bug Found in single-spa-svelte!

The code I initially wrote for this part of the article revealed a problem with the current implementation of the singleSpaSvelte() function in the single-spa-svelte NPM package that prevents us from creating multiple instances of the same parcel. Long story short, we will be working the issue around.

If you are interested in the topic, I suggest you visit the issue I raised in GitHub.


Ok, because of the bug in single-spa-svelte, let's do some modifications to what we have right now. Let's open src/parcels.ts in the SvelteParcels project and modify it as follows:

import Welcome from "./lib/Welcome.svelte";
// @ts-expect-error
import singleSpaSvelte from 'single-spa-svelte';
import { cssLifecycle } from 'vite-plugin-single-spa/ex';


export function welcomeParcel() {
    const lc = singleSpaSvelte({
        component: Welcome
    });
    return {
        bootstrap: [cssLifecycle.bootstrap, lc.bootstrap],
        mount: [cssLifecycle.mount, lc.mount],
        unmount: [cssLifecycle.unmount, lc.unmount],
        update: lc.update
    };        
}
Enter fullscreen mode Exit fullscreen mode

The short explanation is that we are now exporting a factory function that creates new parcel configuration objects whenever it is evaluated. Before, we were exporting a single parcel configuration object. Factories to the rescue!

Rebuild the SvelteParcels project, and then restart the preview Vite server.

Now let's modify src/App.tsx in ReactRoot one more time. The workaround for the bug required only one change (highlighted with a comment below). The rest of the changes pertain to the duplication of the parcel (second set of state data and markup):

import { useState } from 'react'
import reactLogo from './assets/react.svg'
// @ts-expect-error
import Parcel from 'single-spa-react/parcel'
import './App.scss'
import { mountRootParcel } from 'single-spa'

function App() {
  const [loadParcel, setLoadParcel] = useState(false);
  const [loadParcel2, setLoadParcel2] = useState(false);
  const [user, setUser] = useState<string | undefined>(undefined);
  const [user2, setUser2] = useState<string | undefined>(undefined);
  const parcelModuleName = "@test/parcels";

  async function loadWelcomeParcel() {
    const parcelsModule = await import(/* @vite-ignore */ parcelModuleName);
    return parcelsModule.welcomeParcel(); // <----- HERE!  Now welcomeParcel is a function that must be called.
  }

  return (
    <div className="app">
      <div className="row w-100">
        <div className="col-sm-5">
          <div className="root-content">
            <h1><span><img src={reactLogo} alt="React" /></span>React Root Project</h1>
            <p>
              Click the button below to load a Svelte parcel.
            </p>
            <button
              type="button"
              className="btn btn-primary"
              onClick={() => setLoadParcel(v => !v)}
            >
              Toggle Parcel
            </button>
            <button
              type="button"
              className="btn btn-primary ms-2"
              onClick={() => setLoadParcel2(v => !v)}
            >
              Toggle Parcel 2
            </button>
          </div>
        </div>
        <div className="col-sm-7">
          <div className="parcel-content">
            <h3>Parcel Display</h3>
            <div className="mb-3">
              <label htmlFor="user">User's name:</label>
              <input className="form-control" type="text" id="user" onInput={(e) => setUser(e.currentTarget.value)} />
            </div>
            {loadParcel ? <Parcel
              config={loadWelcomeParcel}
              mountParcel={mountRootParcel}
              handleError={console.error}
              user={user}
            /> : null}
            <h3>Parcel Display 2</h3>
            <div className="mb-3">
              <label htmlFor="user2">User's name:</label>
              <input className="form-control" type="text" id="user2" onInput={(e) => setUser2(e.currentTarget.value)} />
            </div>
            {loadParcel2 ? <Parcel
              config={loadWelcomeParcel}
              mountParcel={mountRootParcel}
              handleError={console.error}
              user={user2}
            /> : null}
          </div>
        </div>
      </div>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Re-build ReactRoot and restart the Vite preview server. Reload the webpage.

Start playing around with the "Toggle Parcel" and "Toggle Parcel 2" buttons. Notice any issues? After having both parcel instances on screen, hiding one of them also unmounts the parcel's CSS, making the remaining on-screen instance lose its correct appearance.

This behavior will also be present when simultaneously loading more than one parcel from the same parcel project, even if they are of different components. The current iteration of vite-plugin-single-spa (version 0.3.1) is unfit for parcels. Sad face.

Future Steps

Until vite-plugin-single-spa is updated to account for the parcels scenario, you will have to come up with your own CSS mounting/unmounting algorithm for your parcels project, or look for a solution elsewhere. The good news is that I, the author of vite-plugin-single-spa, will be actively working on this during the next week. You should be able to see v0.4.0 or greater coming up soon.

Potential Implementations

I'll confess I started writing this article knowing very well that my plug-in's cssLifecycle object was going to fail. This means that I have already thought about a few possible solutions. I'll explain now the two that appeal to me the most right now.

The first possible solution adds a new property to the options for micro-frontends:

    /**
     * Defines the plugin options for Vite projects that are single-spa micro-frontentds.
     */
    export type SingleSpaMifePluginOptions = {
        /**
         * The type of single-spa project (micro-frontend or root).
         */
        type?: 'mife';
        /**
         * The server port for this micro-frontend.
         */
        serverPort: number;
        /**
         * The path to the file that exports the single-spa lifecycle functions.
         */
        spaEntryPoint?: string;
        /**
         * Unique identifier given to the project.  It is used to tag CSS assets so the cssLifecyle object in 
         * the automatic module "vite-plugin-single-spa/ex" can properly manage the CSS lifecycle.
         * 
         * If not provided, the project's name (up to the first 20 letters) is used as identifier.
         */
        projectId?: string;
        /**
         * CSS strategy to be used by the cssLifecycle object in the extensions module.
         */
        cssStrategy: 'singleMife' | 'multiMife' // <----- THIS ONE !!!
    };
Enter fullscreen mode Exit fullscreen mode

This new cssStrategy property will govern the CSS mounting algorithm to use. The first option, singleMife, would apply the logic that currently exists in v0.3.1. The second option, multiMife, would apply a similar logic, but this time using a counter that increases every time a parcel/micro-frontend is mounted, and decreases every time a parcel/micro-frontend is unmounted. Only if the counter reaches zero, the algorithm disables (unmounts) the HTML CSS Link elements in the HEAD element.

Why not simply upgrade to the counter version? The counter version would be incompatible with single-spa's unloading mechanism in the sense that cssLifecycle.bootstrap() currently resets the HEAD element by wiping clean any previously injected CSS Link elements. My experience with single-spa is yet very limited, and vite-plugin-single-spa hasn't reached a massive user audience yet; this might become important for users at some point in time. I just don't know if it is safe to forgo this reset feature because loading multiple single-spa lifecycle objects (be them parcels or micro-frontends) causes trouble with the current algorithm that doesn't expect bootstrap() to be called multiple times. This means that I must move the current logic inside bootstrap() to the module itself so it only runs once, so bye-bye to the CSS resetting of the HEAD element. Giving consumers the choice of strategy allows users to opt for one or another more comfortably.

Also, unloading modules exist to support potential HMR of parcel modules. Without the wiping of CSS Link elements, HMR might suffer.

The second solution that appeals to me is to create a parcel-specific project type with very specific rules about how to code it.

    /**
     * Defines the plugin options for Vite projects that are single-spa parcels.
     */
    export type SingleSpaParcelsPluginOptions = {
        type?: 'parcels';
        /**
         * The server port for this micro-frontend.
         */
        serverPort: number;
        parcelEntryPoints?: string | string[];
    };
Enter fullscreen mode Exit fullscreen mode

Almost identical to the micro-frontend type, this type allows multiple entry points, but expects that the project:

  1. Only exports parcels. It cannot export a micro-frontend.

  2. Only exports one parcel per entry point.

  3. Dynamic import of components is off the table because along with the JS splitting comes CSS splitting. This extra piece of CSS cannot be accounted for by my plug-in because it won't be listed in the list of CSS bundles for the entry point chunk and therefore cannot be managed.

With these guarantees in place, I can provide a CSS mounting algorithm that uses counters per CSS bundle to account for multiple instances of the same parcel and to account for any shared CSS bundles that may be needed by different parcels that might be present in the page simultaneously. The cssLifecycle object would need to be told the parcel name so it uses the correct list of CSS bundle filenames.


What do you think? Can you come up with a better solution? Let me know in the comments! I would appreciate the extra help.

Conclusion

Well, this was certainly a long journey. I think the best we can do right now is to summarize our learnings:

  • There is very little difference between a parcel and a micro-frontend.

  • Although you might hear that single-spa informally can load multiple instances of the same parcel/micro-frontend (in single-spa's slack, mostly), the reality is that this was never a design objective of the library. Attempting this requires thorough testing and probably a factory function here and there.

  • single-spa-react has a very handy Parcel component that simplifies the consumption of parcels, although it doesn't work with React's strict mode, most likely due to the double-render that it introduces.

  • The properties injected by single-spa's mountParcel() and mountRootParcel() are: name, domElement, mountParcel, singleSpa and unmountSelf. We must not use these names for other props in the component, or things will get messy. Furthermore, we cannot use as prop names the names of the props of the Parcel component, such as config, or wrapWith.

  • single-spa-svelte has a confirmed bug that prevents it to properly create multiple instances of a parcel/micro-frontend. Wrap the call to singleSpaSvelte() inside a factory function as a workaround.

  • vite-plugin-single-spa v0.3.1 is unfit for parcels in general. Wait for a newer version to come out.

That is all for today. Let me know if you have questions or would like me to cover a specific scenario or topic around single-spa, my plug-in or any combination of frameworks. I'll try to comply.

Happy coding!

Top comments (0)