App shell
The term app shell refers to an architectural approach used in the development of web applications that separates the core application infrastructure and UI from the data. The app shell typically consists of the minimal HTML, CSS and JavaScript required to render the application UI.
In O3, the app shell refers to the code inside the esm-app-shell package. It handles everything from the moment you request a page up until the point before you see anything rendered in the UI. The first port of call to the app shell is the index.ejs file. This file is the entry point for the application and is responsible for rendering the app shell. O3 is a single page application based around a single HTML file that’s generated from the index.ejs template file. The index.ejs template is mostly static HTML with some dynamic values that get interpolated into the template at build time. We pass in things like:
- The default locale.
- The page title.
- The app’s favicon.
- A
<link>tag that is a reference to the import map. - A
<script>tag for the import map. - A
<link>tag that preloads the routes registry, which is the aggregatedroutes.jsonmetadata for the frontend modules. - A
<script>tag for the routes registry.
The template also contains a reference to the main JavaScript file generated from the Rspack app-shell bundle (openmrs.js in development and a hashed openmrs.[contenthash].js file in production). That bundle creates a function called initializeSpa and appends it to the window object, making it available to the global scope. This function is called from the index.ejs template and is responsible for bootstrapping the application. The template also includes:
<div>s where containers for modals, inline notifications, actionable notifications, snackbars, and toast notifications get rendered in the UI.- A loading spinner that is shown during app-shell startup, before single-spa begins routing.
- An error state that gets rendered when the application fails to load.
initializeSpa
The initializeSpa function:
- Sets up utility functions (like
copyTextfor error messages) - Configures global paths and variables on the window object (e.g.
openmrsBase,spaBase,spaEnv,spaVersion, andgetOpenmrsSpaBase) - Wires up the SPA base path by creating a
<base>element in the document head - Initializes the Module Federation shared scope used by frontend modules
- Initializes the module loading mechanism by invoking the
runfunction
run()
The run function orchestrates the complete initialization sequence. It is responsible for:
- Displaying the loading spinner. This spinner stays visible through app-shell startup and is removed after
runShell()starts single-spa. Frontend module code is still loaded lazily when pages, extensions, modals, or workspaces render. - Setting up breakpoints in the UI for
tablet,small-desktopandlarge-desktopviewports. - Wiring up subscriptions to appropriate places in the app shell for toasts, inline notifications, actionable notifications, snackbars, and modals.
- Calling
setupApiModuleto initialize the configuration schema. - Calling
setupHistoryto set up client-side routing. - Calling
registerCoreExtensions, a legacy hook for app-shell-owned extensions. The current app shell does not register breadcrumbs there. - Calling
setupCoreConfigto initialize core configuration. - Calling
setupApps, which reads route maps and registers module metadata without importing the module code yet. - After modules are registered, it calls
finishRegisteringAllAppsto finalize the registration process. - If offline mode is enabled, it sets up offline CSS classes and connectivity handlers before configuration is provided. After
runShell()starts single-spa and the loading spinner closes, it registers the service worker and activates offline support. - Loading configuration from the provided
configUrls. - Calling
runShellwhich starts single-spa and initializes internationalization. - Handling initialization failures with
handleInitFailureif anything goes wrong. - Cleaning up obsolete feature flags.
setupApps()
The setupApps function loads all of the routes from the routes registry. It reads routes from:
<script type="openmrs-routes">tags in the HTML (either inline JSON or from a URL)- Route overrides stored in
localStorage(useful for development and debugging)
After loading all routes, it invokes registerApp() on each module and its route definition. The route definitions stay available on window.installedModules, which lets tools inspect module versions and backend dependency metadata. registerApp() itself then:
- Registers an implicit configuration schema for the application.
- Iterates through the extensions and registers them with the extension registry.
- Iterates through modals, workspaces, workspace groups, workspace windows, and feature flags and registers them.
- Pages get added to a global array. After all modules are registered,
finishRegisteringAllAppssorts pages alphabetically by app name, then creates a<div>element for each page in the DOM. The div is created inside the container specified by the page’scontainerDomIdproperty (defaults toomrs-apps-container). This ensures that single-spa has a predetermined DOM element to mount each page into, which is required for proper routing behavior.
Pages and extensions are implemented as Single Spa objects, which are essentially JavaScript objects that define three lifecycle functions:
bootstrap- This function is called once (and only once) when the application is first loaded. It’s responsible for loading any dependencies the application needs.mount- This function is called when the page is first loaded. It’s responsible for rendering the page. Under the hood, this typically callsReactDOM.createRoot()and renders the React tree into the DOM element that corresponds to the page.unmount- This function is called when the page is unloaded. It’s responsible for cleaning up any resources the page is using.
Both pages and extensions are loaded using the loadLifeCycles() function which takes the app name and component name and returns a Promise that resolves to the single-spa lifecycle object. If you look at a frontend module’s entry point (src/index.ts), you’ll see named exports that invoke either the getAsyncLifecycle or getSyncLifecycle function. These functions allow us to wrap pages and extensions into a format that can be loaded by single-spa. Essentially, the React component gets wrapped in an openmrsComponentDecorator that allows the framework to:
- Handle errors if the rendering fails catastrophically.
- Wire up configuration support for the component.
- Wire up
i18nsupport for the component. - Wire up a Suspense fallback for the component that defaults to
null. - Render the component.
When you define a page, the page definition can have a route property or a routeRegex property. Single-spa uses the location referenced by the route or routeRegex to determine the pages that get rendered into that location via the getActivityFn function. Pages in O3 are essentially single-spa applications with a predetermined <div> tag that they get rendered into. Extensions leverage the single-spa parcel concept - they have the exact same lifecycles as single-spa applications, except that they don’t have a getActivityFn. This means single-spa will never automatically mount or unmount a parcel. Instead, you have to manually tell it when to mount or unmount a parcel. The extension system exists to determine when an extension should be loaded (and subsequently invoke the mount function on it) and unloaded (and subsequently invoke the unmount function on it). Every extension is mounted through the Extension component defined in the framework. This component essentially renders the extension (by invoking single-spa’s mount function) and unmounts the parcel when the component is unmounted. It defines a <div> with a data-extension-id property into which React renders the parcel. The key thing to note about pages and extensions is that once <div>s get created for them, React takes over and renders the page or extension into the DOM.
Module loading
O3 loads frontend modules using import maps and Module Federation. The app shell includes one or more
systemjs-importmap script tags in index.ejs, but the current runtime reads those tags itself through the
dynamic-loading package. SystemJS is no longer the piece that imports module code at runtime.
For each module URL, the app shell appends a <script> tag. When the script loads, it exposes a Module Federation
container on window using the module name transformed into a JavaScript identifier. The app shell then calls the
container’s init and get functions to load the module’s ./start export.
When a page, extension, modal, or workspace is about to render, loadLifeCycles() imports the owning frontend module,
runs that module’s startupApp() once if it exports one, marks the module as loaded in the configuration system, and then
calls the named lifecycle export referenced by routes.json.
Initialization sequence
The complete initialization sequence follows this order:
- Template rendering: The
index.ejstemplate is rendered with dynamic values injected at build time. - initializeSpa: Sets up global variables and paths, then calls
run(). - run(): Orchestrates the initialization:
- Sets up UI infrastructure (breakpoints, notifications, modals)
- Initializes API module and configuration schema
- Sets up routing history
- Runs the legacy core-extension registration hook
- Loads route maps and registers module metadata via
setupApps() - Finalizes module registration
- Sets up offline CSS classes and connectivity handlers if offline mode is enabled
- Loads configuration files
- Starts single-spa routing via
runShell() - Handles any initialization errors
- Closes the startup loading spinner
- Registers the service worker and activates offline support if offline mode is enabled
- Fires the
startedevent, which triggers follow-up work such as obsolete feature flag cleanup
- runShell(): Finalizes the startup by:
- Setting up internationalization (i18n)
- Registering the default calendar
- Starting single-spa with
start(), which begins routing and rendering pages
Offline support
When offline mode is enabled (via the offline configuration option), the app shell:
- Registers a service worker (
service-worker.js) for caching and offline functionality - Toggles the
omrs-offlineclass on the body element based on connectivity - Registers offline handlers for connectivity changes
- Activates offline capability for data synchronization
- Sets up static dependency precaching for offline use
Error handling
If initialization fails at any point, the handleInitFailure function:
- Catches the error and displays the error template from
index.ejs - Shows a user-friendly error message with a reload button
- Logs detailed error information to the console
- Provides a copy button for the error message
Route overrides
For development and debugging purposes, you can override routes by storing them in localStorage with keys prefixed with openmrs-routes:. Route overrides are only active when window.spaEnv === "development"; production builds ignore them. The app shell loads the overrides that were present when the page loaded and merges them with the routes from the registry. The stored value must be JSON, either:
- A JSON-serialized routes object for that module
- A JSON-serialized URL string pointing to a
routes.jsonfile to fetch
This allows developers to test different module versions or configurations without rebuilding the entire application. For example:
localStorage.setItem('openmrs-routes:@openmrs/esm-my-app', JSON.stringify({
pages: [/* ... */],
extensions: [/* ... */]
}));For a URL-valued override, store the URL with JSON.stringify("https://example.org/routes.json"); a raw URL string will not be parsed as a valid override.