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.

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:

  1. Factor out static metadata into a routes.json file

  2. Factor out dynamic metadata into a startupApp activator function

  3. Upgrade core dependencies

  4. Check out the Troubleshooting guide

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 the route property in the original pages 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 of route or routeRegex.

  • 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 to true. 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 to true. 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 the load property of the page definition. Looking at the login example:

    We could extract the following component:

  • route - we can extract this directly from the route 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 same component property for both of them.

  • online - we'll use the default value true.

  • offline - we'll use the default value true.

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 the name property in the original extensions array.

  • component - string property that refers to the name of the component exported by this frontend module. This is the same as the component property of the pages 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 the slot property in the original extensions array.

  • privilege - string or array 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 to true. 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 to true. 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 the name property of the extension definition. In the Login example, this would be location-picker, logout-button, and location-changer respectively.

  • slot - we can extract this directly from the slot property of the extension definition. In the Login example, this would be location-picker, user-panel-actions-slot, and user-panel-slot respectively.

  • online - we'll use the default value true.

  • offline - we'll use the default value true.

  • order - we'll grab the order property from the location-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 and extensions.

  • 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 and version variable

  • The setupOpenMRS function

  • The sharedOnlineOfflineProps object

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

 

Screenshot of the error message

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: