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
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
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
Let's do similarly in the parcels project:
npm i vite-plugin-single-spa single-spa-svelte
npm i -D bootstrap sass
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));
}
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
You should now see a root project similar to this:
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";
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>
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>
This should show you this:
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
});
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'
})],
});
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"
}
}
Then, src/importMap.dev.json
:
{
"imports": {
"@test/parcels": "http://localhost:4201/src/parcels.ts"
}
}
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'
})],
})
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
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 namedwelcomeParcel
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 theconsole.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 />
)
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...
Save and try again. Success! Kind of. Something unexpected is in sight. Let me show you a screenshot:
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:
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>
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}
Save and test:
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);
}
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>
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}
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
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>
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&type=style&lang.css">div.welcome.s-U5UaycqR-l4Y{display:flex;flex-flow:row nowrap;gap:1em;align-items:baseline;width:100%}.s-U5UaycqR-l4Y{}</style>
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
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
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
};
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!!!!
});
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
};
}
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
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 !!!
};
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[];
};
Almost identical to the micro-frontend type, this type allows multiple entry points, but expects that the project:
Only exports parcels. It cannot export a micro-frontend.
Only exports one parcel per entry point.
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 (insingle-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 handyParcel
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
'smountParcel()
andmountRootParcel()
are:name
,domElement
,mountParcel
,singleSpa
andunmountSelf
. 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 theParcel
component, such asconfig
, orwrapWith
.single-spa-svelte
has a confirmed bug that prevents it to properly create multiple instances of a parcel/micro-frontend. Wrap the call tosingleSpaSvelte()
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!