Migrating to esm-core v5
This guide is for migrating frontend modules to Core v5. Please check your frontend module's package.json
entries for @openmrs/esm-framework
and openmrs
to see if you need to migrate. If you're running anything higher than @openmrs/esm-framework@5.0.0
and openmrs@5.0.0
, you're already on Core v5. If not, then you need to migrate.
- 1 Introduction
- 2 Case Study: Login frontend module
- 3 Factor out static metadata
- 4 Factor out dynamic metadata
- 4.1 1. Move the moduleName and options variables to the top level
- 4.2 2. Make the importTranslation function a named export
- 4.3 3. Make pages and extensions named exports
- 4.4 4. Create a startupApp function and move the defineConfigSchema call into it
- 4.5 5. Remove extraneous metadata
- 4.6 6. Use fewer dynamic imports for improved performance
- 4.7 Final index.ts file
- 5 Upgrade core dependencies
- 6 Troubleshooting
- 6.1 I've pulled the latest changes but I can't get a local dev server running
- 6.2 I'm getting a SyntaxError: Unexpected token 'export' error when I run tests that's related to Dexie
- 6.3 I'm getting a Module not found: Error: Can’t resolve ‘css-loader’ error
- 6.4 I'm getting a minified single-spa error #10: Invalid mount lifecycle on parcel when I run my frontend module
- 7 More examples
Introduction
O3 provides a powerful module loading system that handles loading of frontend modules in the app shell. This system leverages Webpack module federation and forms the basis of our microfrontends architecture. However, it does suffer from a few historical drawbacks:
All frontend modules get sequentially loaded at app startup time, which has a big performance impact.
Because we're loading all modules at startup, we're also incurring the cost of executing all the dynamic import code for each module, even if the module is not used in the current page.
To address these issues, we've introduced a new module loading mechanism in Core v5. This new system essentially swaps out the app shell's implementation that loads all the frontend modules from the import map for an implementation that loads modules on demand. This means that modules are only loaded when they are needed, and only the code that is needed is executed. This yields a significant performance improvement compared to the old system. For example, in local testing, we've seen an approximately 3x reduction in the number of network requests needed to load the login page. Additionally, we've seen improvements to core web vitals metrics such as First Contentful Paint (FCP), Largest Contentful Paint (LCP), and Speed Index (as tested using Lighthouse on Google Chrome).
To leverage these improvements, you'll need to migrate your existing frontend modules to Core v5. This guide will walk you through the process of doing so.
In general, you need to do the following:
Factor out static metadata into a
routes.json
fileFactor out dynamic metadata into a
startupApp
activator function
Case Study: Login frontend module
Let's take a look at the Login frontend module as an example. The original index.ts
file for the module looks like this:
import { getAsyncLifecycle, defineConfigSchema } from "@openmrs/esm-framework";
import { configSchema } from "./config-schema";
declare var __VERSION__: string;
// __VERSION__ is replaced by Webpack with the version from package.json
const version = __VERSION__;
const importTranslation = require.context("../translations", false, /.json$/, "lazy");
const backendDependencies = {
"webservices.rest": "^2.24.0",
};
const sharedOnlineOfflineProps = {
online: {
isLoginEnabled: true,
},
offline: {
isLoginEnabled: false,
},
};
function setupOpenMRS() {
const moduleName = "@openmrs/esm-login-app";
const options = {
featureName: "login",
moduleName,
};
defineConfigSchema(moduleName, configSchema);
return {
pages: [
{
load: getAsyncLifecycle(() => import("./root.component"), options),
route: "login",
...sharedOnlineOfflineProps,
},
{
load: getAsyncLifecycle(() => import("./root.component"), options),
route: "logout",
...sharedOnlineOfflineProps,
},
],
extensions: [
{
name: "location-picker",
slot: "location-picker",
load: getAsyncLifecycle(() => import("./location-picker/location-picker.component"), options),
...sharedOnlineOfflineProps,
},
{
name: "logout-button",
slot: "user-panel-actions-slot",
load: getAsyncLifecycle(() => import("./logout/logout.component"), options),
online: true,
offline: false,
},
{
name: "location-changer",
slot: "user-panel-slot",
order: 1,
load: getAsyncLifecycle(() => import("./change-location-link/change-location-link.component"), options),
...sharedOnlineOfflineProps,
},
],
};
}
export { setupOpenMRS, importTranslation, backendDependencies, version };Factor out static metadata
Factor out static metadata
Each frontend module defines metadata that are either static or dynamic in nature. Static metadata include:
backendDependencies
- the versions of backend dependencies that the module depends on.pages
- the pages that the module provides.extensions
- the extensions that the module provides.
These metadata are static in the sense that they do not change at runtime. They are also the metadata that are used by the app shell to load the module.
Looking at the entrypoint for the Login
example from above, the static metadata we need to factor out are shown in below:
import { getAsyncLifecycle, defineConfigSchema } from "@openmrs/esm-framework";
import { configSchema } from "./config-schema";
declare var __VERSION__: string;
// __VERSION__ is replaced by Webpack with the version from package.json
const version = __VERSION__;
const importTranslation = require.context("../translations", false, /.json$/, "lazy");
const backendDependencies = {
"webservices.rest": "^2.24.0",
};
const sharedOnlineOfflineProps = {
online: {
isLoginEnabled: true,
},
offline: {
isLoginEnabled: false,
},
};
function setupOpenMRS() {
const moduleName = "@openmrs/esm-login-app";
const options = {
featureName: "login",
moduleName,
};
defineConfigSchema(moduleName, configSchema);
return {
pages: [
{
load: getAsyncLifecycle(() => import("./root.component"), options),
route: "login",
...sharedOnlineOfflineProps,
},
{
load: getAsyncLifecycle(() => import("./root.component"), options),
route: "logout",
...sharedOnlineOfflineProps,
},
],
extensions: [
{
name: "location-picker",
slot: "location-picker",
load: getAsyncLifecycle(() => import("./location-picker/location-picker.component"), options),
...sharedOnlineOfflineProps,
},
{
name: "logout-button",
slot: "user-panel-actions-slot",
load: getAsyncLifecycle(() => import("./logout/logout.component"), options),
online: true,
offline: false,
},
{
name: "location-changer",
slot: "user-panel-slot",
order: 1,
load: getAsyncLifecycle(() => import("./change-location-link/change-location-link.component"), options),
...sharedOnlineOfflineProps,
},
],
};
}
export { setupOpenMRS, importTranslation, backendDependencies, version };
Let's walk through the changes that we need to make to this file to build out the routes.json
file step by step.
1. Create a routes.json
file
Create a routes.json
file in the module's root directory:
{
"$schema": "https://json.openmrs.org/routes.schema.json"
}
The $schema
property points to the routes schema file which is a standard JSON schema that enables your IDE to provide autocompletion and validation for the routes.json
file.
2: Move backendDependencies
backendDependencies
represents a list of backend modules necessary for this frontend module to work and the corresponding required versions. Move backendDependencies
from index.ts
to routes.json
as follows:
3. Move pages
pages
are automatically mounted based on a route.
Each page in the pages
array is represented by a JSON object with the following properties:
component
- a string property that represents the name of the component exported by this frontend module.route
- string or boolean property that represents the route that the page is accessible at. This is the same as theroute
property in the originalpages
array. If set to a string property, this is used to indicate that this page is accessible at the specified route. For example,name
will match when the current page is${window.spaBase/name}
. If a boolean, this indicates the component should always be rendered or should never be rendered.routeRegex
- A regular expression that is used to match the current route to determine if this page should be rendered. Note that${window.spaBase}
gets removed before attempting to match the route, so setting this to^name.+
will any route that starts with${window.spaBase}/name
. You can only specify one ofroute
orrouteRegex
.privilege
- array or string property that represents one or more privileges that a user must have in order for this page to be rendered. If the user does not have the specified privileges, the page will not be rendered.online
- optional boolean property. Defaults totrue
. Determines whether the component renders while the browser is connected to the internet. If false, the page will not be rendered while online.offline
- optional boolean property. Defaults totrue
. Determines whether the component renders while the browser is not connected to the internet. If false, the page will not be rendered while offline.order
- integer property. Determines the order in which the DOM element that renders this page is rendered. Should be used sparingly, but is sometimes necessary to ensure the resulting markup is correct. Minimum value is 0.
component
is the only required property. All other properties are optional.
To move pages
from index.ts
to routes.json
, we need to extract the following properties from each page definition in the pages
array:
component
- the named export of the component. This gets obtained from theload
property of the page definition. Looking at the login example:We could extract the following component:
route
- we can extract this directly from theroute
property of the page definition. In the login example, this would be"login"
and"logout"
respectively. These routes both use the same component, so we can use the samecomponent
property for both of them.online
- we'll use the default valuetrue
.offline
- we'll use the default valuetrue
.
Putting this together gives us the following pages
definition:
4. Move extensions
extensions
is an array of all the extensions supported by a frontend module. Extensions can be mounted in extensions slots via declarations in the routes.json
file or dynamically via configuration.
Each extension in the extensions
array is represented by a JSON object with the following properties:
name
-string
property that refers to the name of the extension. This is the same as thename
property in the originalextensions
array.component
-string
property that refers to the name of the component exported by this frontend module. This is the same as thecomponent
property of thepages
array from the previous step.slot
-string
property that refers to the name of the slot that this extension should be mounted in. This is the same as theslot
property in the originalextensions
array.privilege
-string
orarray
property that refers to the privilege(s) that a user must have in order for this extension to be rendered.online
- optional boolean property. Defaults totrue
. Determines whether the component renders while the browser is connected to the internet. If false, the page will not be rendered while online.offline
- optional boolean property. Defaults totrue
. Determines whether the component renders while the browser is not connected to the internet. If false, the page will not be rendered while offline.order
-integer
property. Determines the order in which this component renders in its default extension slot. Note that this can be overridden by configuration. Minimum value is 0.meta
-object
property that describes any properties that get passed down to the extension when it gets loaded.
name
and component
are required properties. All other properties are optional.
To move extensions
from index.ts
to routes.json
, we need to extract the following properties from each extension definition in the extensions
array:
name
- we can extract this directly from thename
property of the extension definition. In the Login example, this would belocation-picker
,logout-button
, andlocation-changer
respectively.slot
- we can extract this directly from theslot
property of the extension definition. In the Login example, this would belocation-picker
,user-panel-actions-slot
, anduser-panel-slot
respectively.online
- we'll use the default valuetrue
.offline
- we'll use the default valuetrue
.order
- we'll grab theorder
property from thelocation-changer
extension definition, which is 1.
Putting this all together gives us the following extensions
definition:
Final routes.json
file
The final routes.json
file looks like this:
Factor out dynamic metadata
Dynamic metadata include:
The
importTranslation
function.Named exports for
pages
andextensions
.The
startupApp
activator function.The frontend module's
options
object.
The app shell does not need to know about these metadata at initial module load time. As such, frontend modules can retain these metadata in their index.ts
files. However, they need to be moved outside of the setupOpenMRS
function and prefixed with export
so that they can be imported by the app shell at runtime.
Going back to the Login app example from above, the dynamic metadata we need to factor out are highlighted below:
Let's walk through the changes that we need to make to this file to factor out the dynamic metadata step by step.
1. Move the moduleName
and options
variables to the top level
Because we're going to get rid of the setupOpenMRS
function, we need to move the moduleName
and options
variable and the defineConfigSchema
function outside of setupOpenMRS
to the top level.
2. Make the importTranslation
function a named export
3. Make pages and extensions named exports
Each page and extension in the pages
and extensions
arrays needs to be a named export at the top-level.
These correspond to the component
property of each page and extension in the pages
and extensions
arrays in the routes.json
file.
4. Create a startupApp
function and move the defineConfigSchema
call into it
This startupApp
function will contain all the functions that should be executed by the app shell at runtime, including the defineConfigSchema
function which sets up configuration for the frontend module.
5. Remove extraneous metadata
Remove the following metadata:
backendDependencies
The
__VERSION__
type declaration andversion
variableThe
setupOpenMRS
functionThe
sharedOnlineOfflineProps
objectThe
export
statement at the bottom of the file
6. Use fewer dynamic imports for improved performance
By importing components directly instead of using a function call, we can reduce the number of chunks that Webpack creates for a frontend module. Browsers have a limit on the number of network requests that can be made at any given time. For example, Chrome has a limit of 6 concurrent requests per domain. This means that the more chunks we have, the longer it takes to load a frontend module. We've seen a marked performance improvement from reducing the number of chunks for a frontend module, especially for modules with a large number of components.
The getAsyncLifecycle
function is only necessary when we need to dynamically import a component. To leverage this optimization, we can import the components directly and use the getSyncLifecycle
function instead. For example, instead of doing this:
We can do this:
Going back to the Login example, we can tweak the component imports as follows:
Final index.ts
file
Bringing all these changes together, we get the following index.ts
file:
Upgrade core dependencies
Next, you'll need to upgrade to the latest versions of @openmrs/esm-framework
and openmrs
. To do so, run:
Check that you have the latest version of the framework by running:
You should see something like this:
This step is important because the latest versions of the framework include critical bug fixes and improvements to the app shell and the core framework.
Troubleshooting
I've pulled the latest changes but I can't get a local dev server running
If you've pulled the latest changes and the dev server won't start, make sure you've run yarn
to get the latest dependencies.
I'm getting a SyntaxError: Unexpected token 'export'
error when I run tests that's related to Dexie
If you're getting this error:
This means that there's a problem with the module import mapping for the dexie
package in your Jest configuration. To fix this, amend the moduleNameMapper
config option for dexie
in your jest.config.js
to the following:
If your Jest config is in a JSON file, you might want to move that over to a JavaScript file instead. See this commit's diff for guidance on what to change.
I'm getting a Module not found: Error: Can’t resolve ‘css-loader’
error
This error means that you're missing css-loader dependency, which the framework uses . To fix this, make sure you install swc-node/loader
as a devDependency in your frontend module:
I'm getting a minified single-spa error #10: Invalid mount lifecycle on parcel
when I run my frontend module
This error means that your frontend module has an invalid mount lifecycle function. The usual culprit is a misconfigured named export in your app's index.ts
file. Make sure that your named exports directly reference exports from invoking getAsyncLifecycle
and getSyncLifecycle
:
This is a common mistake when upgrading from the old frontend module structure to the new one. Don't get caught out.
More examples
To see more examples of how to upgrade a frontend module to the new structure, check out index.ts
and routes.json
files in any of our key repositories. For example, below are links to the index.ts
and routes.json
files for the @openmrs/esm-patient-chart-app
frontend module in the Patient Chart repo: