Info |
---|
This guide is for migrating frontend modules to Core v5. Please check your frontend module's |
Table of Contents | ||
---|---|---|
|
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:
...
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).
...
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:
Code Block | ||
---|---|---|
| ||
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.jsonconstjson 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
...
Looking at the entrypoint for the Login
example from above, the static metadata we need to factor out are highlighted shown in below:
Code Block | ||
---|---|---|
| ||
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.jsonconstjson 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 }; |
...
Create a routes.json
file in the module's root directory:
Code Block | ||
---|---|---|
| ||
{ "$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.
...
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:
Code Block | ||
---|---|---|
| ||
{ "$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.
...
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.ℹ️
Info |
---|
|
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:Code Block language ts { 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:
Code Block language ts 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:
Code Block | ||
---|---|---|
| ||
{ "$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.
...
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.ℹ️
Info |
---|
|
To move extensions
from index.ts
to routes.json
, we need to extract the following properties from each extension definition in the extensions
array:
...
Putting this all together gives us the following extensions
definition:
Code Block | ||
---|---|---|
| ||
{ "$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:
Code Block | ||
---|---|---|
| ||
{ "$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
...
Code Block | ||
---|---|---|
| ||
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.jsonconstjson 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 }; |
...
Code Block | ||
---|---|---|
| ||
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
...
Code Block | ||
---|---|---|
| ||
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.
...
Code Block | ||
---|---|---|
| ||
export function startupApp() { defineConfigSchema(moduleName, configSchema); } |
5. Remove extraneous metadata
...
6. Use fewer dynamic imports for improved performance
...
Info |
---|
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.
...
Code Block | ||
---|---|---|
| ||
import rootComponent from './root.component'; export const root = getSyncLifecycle(rootComponent, options); |
...
Code Block | ||
---|---|---|
| ||
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:
Code Block | ||
---|---|---|
| ||
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
...
Code Block | ||
---|---|---|
| ||
└─ @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.
...
Code Block | ||
---|---|---|
| ||
export { Dexie$1 as Dexie, RangeSet, Dexie$1 as default, liveQuery, mergeRanges, rangesOverlap }; ^^^^^^ SyntaxError: Unexpected token ‘export’ |
...
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.
...
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:
...
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
:
Code Block | ||
---|---|---|
| ||
// This is incorrect. `root`. Remove the function call callexportexport const root = () => getAsyncLifecycle(() => import("./root.component"), options); // This is correct way to import the componentexportcomponent 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.
...
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: