Migrating to 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.
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 (opens in a new tab) in Core v5 (opens in a new tab). 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
file - Factor out dynamic metadata into a
startupApp
activator function - Upgrade core dependencies
- Check out the Troubleshooting guide
Case Study: Login frontend module
Let's take a look at the Login (opens in a new tab) 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
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 highlighted 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 (opens in a new tab) file which is a standard JSON schema (opens in a new tab) 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:
{
"$schema": "https://json.openmrs.org/routes.schema.json",
"backendDependencies": {
"webservices.rest": "^2.24.0"
}
}
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:{ load: getAsyncLifecycle(() => import("./root.component"), options), route: "login", // Properties of the `sharedOnlineOfflineProps` object get spread here for brevity online: { isLoginEnabled: true, }, offline: { isLoginEnabled: false, } }, { load: getAsyncLifecycle(() => import("./root.component"), options), route: "logout", // Properties of the `sharedOnlineOfflineProps` object get spread here for brevity online: { isLoginEnabled: true, }, offline: { isLoginEnabled: false, } }
We could extract the following component:
export const root = getAsyncLifecycle(() => import("./root.component"), options);
-
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:
{
"$schema": "https://json.openmrs.org/routes.schema.json",
"backendDependencies": {
"webservices.rest": "^2.24.0"
},
"pages": [
{
"component": "root",
"route": "login",
"online": true,
"offline": true
},
{
"component": "root",
"route": "logout",
"online": true,
"offline": true
}
]
}
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
andcomponent
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:
{
"$schema": "https://json.openmrs.org/routes.schema.json",
"backendDependencies": {
"webservices.rest": "^2.24.0"
},
"pages": [
{
"component": "root",
"route": "login",
"online": true,
"offline": true
},
{
"component": "root",
"route": "logout",
"online": true,
"offline": true
}
],
"extensions": [
{
"name": "location-picker",
"slot": "location-picker",
"component": "locationPicker",
"online": true,
"offline": true
},
{
"name": "logout-button",
"slot": "user-panel-actions-slot",
"component": "logoutButton",
"online": true,
"offline": true
},
{
"name": "location-changer",
"slot": "user-panel-slot",
"component": "changeLocationLink",
"online": true,
"offline": true,
"order": 1
}
]
}
Final routes.json
file
The final routes.json
file looks like this:
{
"$schema": "https://json.openmrs.org/routes.schema.json",
"backendDependencies": {
"webservices.rest": "^2.24.0"
},
"pages": [
{
"component": "root",
"route": "login",
"online": true,
"offline": true
},
{
"component": "root",
"route": "logout",
"online": true,
"offline": true
}
],
"extensions": [
{
"name": "location-picker",
"slot": "location-picker",
"component": "locationPicker",
"online": true,
"offline": true
},
{
"name": "logout-button",
"slot": "user-panel-actions-slot",
"component": "logoutButton",
"online": true,
"offline": true
},
{
"name": "location-changer",
"slot": "user-panel-slot",
"component": "changeLocationLink",
"online": true,
"offline": true,
"order": 1
}
]
}
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:
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 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.
import { getAsyncLifecycle, defineConfigSchema } from "@openmrs/esm-framework";
import { configSchema } from "./config-schema";
const moduleName = "@openmrs/esm-login-app";
const options = {
featureName: "login",
moduleName,
};
2. Make the importTranslation
function a named export
export const importTranslation = require.context("../translations", false, /.json$/, "lazy");
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.
export const root = getAsyncLifecycle(() => import("./root.component"), options);
export const locationPicker = getAsyncLifecycle(() => import("./location-picker/location-picker.component"), options);
export const logoutButton = getAsyncLifecycle(() => import("./logout/logout.component"), options);
export const changeLocationLink = getAsyncLifecycle(
() => import("./change-location-link/change-location-link.component"),
options
);
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.
export function startupApp() {
defineConfigSchema(moduleName, configSchema);
}
5. Remove extraneous metadata
Remove the following metadata:
backendDependencies
- The
__VERSION__
type declaration andversion
variable - The
setupOpenMRS
function - The
sharedOnlineOfflineProps
object - The
export
statement at the bottom of the file
6. Use fewer dynamic imports for improved performance
This is a recent performance optimization that has been shown to reduce the number of JavaScript files loaded at runtime significantly.
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:
export const root = getAsyncLifecycle(() => import("./root.component"), options);
We can do this:
import rootComponent from './root.component';
export const root = getSyncLifecycle(rootComponent, options);
Going back to the Login example, we can tweak the component imports as follows:
import { getAsyncLifecycle, defineConfigSchema } from "@openmrs/esm-framework";
import { configSchema } from "./config-schema";
import rootComponent from "./root.component";
import locationPickerComponent from "./location-picker/location-picker.component";
import logoutButtonComponent from "./logout/logout.component";
import changeLocationLinkComponent from "./change-location-link/change-location-link.component";
const moduleName = "@openmrs/esm-login-app";
const options = {
featureName: "login",
moduleName,
};
export const importTranslation = require.context("../translations", false, /.json$/, "lazy");
export function startupApp() {
defineConfigSchema(moduleName, configSchema);
}
export const root = getSyncLifecycle(rootComponent, options);
export const locationPicker = getSyncLifecycle(locationPickerComponent, options);
export const logoutButton = getSyncLifecycle(logoutButtonComponent, options);
export const changeLocationLink = getSyncLifecycle(
changeLocationLinkComponent,
options
);
Final index.ts
file
Bringing all these changes together, we get the following index.ts
file:
import { getAsyncLifecycle, defineConfigSchema } from "@openmrs/esm-framework";
import { configSchema } from "./config-schema";
import rootComponent from "./root.component";
import locationPickerComponent from "./location-picker/location-picker.component";
import logoutButtonComponent from "./logout/logout.component";
import changeLocationLinkComponent from "./change-location-link/change-location-link.component";
const moduleName = "@openmrs/esm-login-app";
const options = {
featureName: "login",
moduleName,
};
export const importTranslation = require.context("../translations", false, /.json$/, "lazy");
export function startupApp() {
defineConfigSchema(moduleName, configSchema);
}
export const root = getSyncLifecycle(rootComponent, options);
export const locationPicker = getSyncLifecycle(locationPickerComponent, options);
export const logoutButton = getSyncLifecycle(logoutButtonComponent, options);
export const changeLocationLink = getSyncLifecycle(
changeLocationLinkComponent,
options
);
Upgrade core dependencies
Next, you'll need to upgrade to the latest versions of @openmrs/esm-framework
and openmrs
. To do so, run:
yarn up openmrs@next @openmrs/esm-framework@next
Check that you have the latest version of the framework by running:
yarn why openmrs
You should see something like this:
└─ @openmrs/esm-form-builder-app@workspace:.
└─ openmrs@npm:5.0.3-pre.846 (via npm:next)
This step is important because the latest versions of the framework include critical bug fixes and improvements (opens in a new tab) 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:
export { Dexie$1 as Dexie, RangeSet, Dexie$1 as default, liveQuery, mergeRanges, rangesOverlap };
^^^^^^
SyntaxError: Unexpected token ‘export’
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:
'^dexie$': require.resolve('dexie')
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 (opens in a new tab) 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 (opens in a new tab) dependency, which the framework uses . To fix this, make sure you install swc-node/loader
as a devDependency in your frontend module:
yarn add -D css-loader
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 incorrect. `root`. Remove the function call
export const root = () => getAsyncLifecycle(() => import("./root.component"), options);
// This is correct way to import the component
export const root = getAsyncLifecycle(() => import("./root.component"), options);
This is a common mistake (opens in a new tab) 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: