Skip to content

Latest commit

 

History

History
1200 lines (867 loc) · 43.8 KB

File metadata and controls

1200 lines (867 loc) · 43.8 KB

Migrating an MFE to frontend-base

To use the latest version of frontend-base from a git repository, you'll need to use npm pack and install it into your MFE from the resulting .tgz file.

Following these steps turns your MFE into a library that can be built using frontend-base and its shell application. It involves deleting a lot of unneeded dependencies and code.

Clone this repository

Clone this repository as a peer of your micro-frontend folder(s).

npm install and npm run build in frontend-base

You'll need to install dependencies and then build this repo at least once.

Remove undesired features

Here or at any point below, consider removing any undesired or previously deprecated features from the codebase, such as organization-specific code. (Just remember to follow the DEPR process for previously undeprecated code.) This will make the refactoring process proportionately easier.

Modernize the README

You can start by modernizing the main README, such as:

  • Removing references to the devstack, leaving only Tutor instructions
  • Change "MFE" to "frontend app"

As you refactor the app, come back to the README and update it accordingly.

Change dependencies in package.json in MFE

Uninstall replaced dependencies

  • Uninstall @edx/frontend-platform
  • Uninstall @openedx/frontend-build
  • Uninstall @edx/frontend-component-header if it is installed.
  • Uninstall @edx/frontend-component-footer if it is installed.
  • Uninstall @openedx/frontend-plugin-framework if it is installed.
npm uninstall @edx/frontend-platform @openedx/frontend-build
npm uninstall @edx/frontend-component-header @edx/frontend-component-footer @openedx/frontend-plugin-framework

Remove obsolete dependencies

We don't need these anymore, remove them from the package.json dependencies and remove any imports of them in the code:

  • @edx/reactifex
  • husky
  • glob

Delete package-lock.json and node_modules

rm package-lock.json
rm -rf node_modules

Move dependencies to peerDependencies

Dependencies shared with the shell should be moved to peerDependencies. These include:

"dependencies" {
-  "@openedx/paragon": "^23.4.5",
-  "@tanstack/react-query": "^5.81.2",
-  "react": "^17.0.2",
-  "react-dom": "^17.0.2",
-  "react-router": "^6.26.1",
-  "react-router-dom": "^6.26.1",
},
"peerDependencies": {
+  "@openedx/paragon": "^23",
+  "@tanstack/react-query": "^5",
+  "react": "^18",
+  "react-dom": "^18",
+  "react-router": "^6",
+  "react-router-dom": "^6",
}

Note that it's possible that when doing this, you encounter peer conflict errors that you must resolve. A good way to do this is to temporarily remove all dependencies, add the peerDependencies and npm i, then add devDependencies and npm i again, followed by your other dependencies. This will ensure that your dependency versions work around the peer dependencies, rather than the other way around.

Run a fresh npm install

npm install

This gives us a clean baseline. Historically, changes to dependencies have caused subtle and confusing problems without a clean re-installation like the above.

Add frontend-base to dependencies

Run:

npm i --save-peer @openedx/frontend-base

Your package.json should now have a line like this:

"peerDependencies": {
+ "@openedx/frontend-base": "^1.0.0",
},

But change it to look like this:

"peerDependencies": {
+ "@openedx/frontend-base": "^1.0.0 || 0.0.0-dev",
},

The 0.0.0-dev alternative is the placeholder version used in the source checkout — semantic-release replaces it with the real version at publish time, but in npm workspaces the package still needs to satisfy peer dependency checks. The || lets both scenarios work.

Edit package.json scripts

With the exception of any custom scripts, replace the scripts section of your MFE's package.json file with the following:

  "scripts": {
    "build": "make build",
    "build:ci": "make build-ci",
    "clean": "make clean",
    "dev": "PORT=YOUR_PORT PUBLIC_PATH=/YOUR_APP_NAME openedx dev",
    "i18n_extract": "openedx formatjs extract",
    "lint": "openedx lint .",
    "lint:fix": "openedx lint --fix .",
    "prepack": "npm run clean && npm run build",
    "snapshot": "openedx test --updateSnapshot",
    "test": "openedx test --coverage --passWithNoTests"
  },

The build script invokes a Makefile target. You'll need to install tsc-alias as a dev dependency:

npm install --save-dev tsc-alias

Also:

  • Replace YOUR_PORT with the desired port, of course.
  • Replace YOUR_APP_NAME with the basename used on your site.config, not doing this will result in only the root route working.
  • Note that fedx-scripts no longer exists, and has been replaced with openedx.

Tip

Why change fedx-scripts to openedx? A few reasons. One, the Open edX project shouldn't be using the name of an internal community of practice at edX for its frontend tooling. Two, some dependencies of your MFE invariably still use frontend-build for their own build needs. This means that they already installed fedx-scripts into your node_modules/.bin folder. Only one version can be in there, so we need a new name. Seemed like a great time for a naming refresh. |

Last but not least, add clean: and build: targets to your Makefile. The build target compiles TypeScript to JavaScript, copies all SCSS and asset files from src/ into dist/ preserving directory structure, and finally uses tsc-alias to rewrite @src path aliases to relative paths:

Note that build intentionally does not depend on clean. This allows incremental rebuilds during development (especially in workspace mode, where a watcher triggers build on every change). The prepack script in package.json runs clean && build explicitly, so published packages always start fresh.

clean:
	rm -rf dist

build:
	tsc --project tsconfig.build.json
	find src -type f \( -name '*.scss' -o -path '*/assets/*' \) -exec sh -c '\
	  for f in "$$@"; do \
	    d="dist/$${f#src/}"; \
	    mkdir -p "$$(dirname "$$d")"; \
	    cp "$$f" "$$d"; \
	  done' sh {} +
	tsc-alias -p tsconfig.build.json

build-ci:
	SITE_CONFIG_PATH=site.config.ci.tsx openedx build

Note that the find command copies all files under assets/ directories regardless of type, so you don't need to enumerate asset extensions. Also note that tsc-alias runs after the copy step so that it can resolve @src aliases pointing to asset files. If it ran before, it wouldn't find them and would omit them from the relative path conversion.

The build-ci target is separate from build because apps are distributed build-less: build compiles only the library dist/ via tsc, which doesn't verify that the app can actually be webpack-bundled as a deployable site. build:ci runs openedx build against a dedicated site.config.ci.tsx (which imports the real app) so webpack traverses the actual app graph, catching errors such as broken imports in lazy-loaded routes that neither tsc nor Jest would surface. A dedicated CI config is used rather than site.config.dev.tsx (which may include dev--only concerns) or site.config.test.tsx (which typically registers an inline app stub for Jest's sake and therefore never pulls the real app into the webpack graph). The CI config's URLs and environment value are inert at build time, since nothing executes the produced bundle.

Add a corresponding step to your GitHub Actions workflow after the existing build step, so CI fails on any breakage that would only surface at webpack bundle time:

- name: Build (CI)
  run: npm run build:ci

Other package.json edits

  • Change the author to "Open edX"

exports

Define the public API for your package:

"exports": {
  ".": "./dist/index.js"
},

The exports map decouples your public API from the internal dist/ directory structure. Consumers import from clean paths and the map resolves them to the actual files in dist/.

files

Package the compiled output in dist:

"files": [
  "/dist"
],

sideEffects

This tells webpack that the code from the library can be safely tree-shaken, except for CSS/SCSS files which are imported purely for their side effects.

"sideEffects": [
  "*.css",
  "*.scss"
],

Namespace, author, etc

Finally, make sure the following fields are set properly:

"name": "@openedx/frontend-app-[YOUR_APP]",
"version": "1.0.0",
"author": "Open edX",
"license": "AGPL-3.0",

Clean up .npmignore

Since we use the files field in package.json to whitelist only /dist, the .npmignore file is largely unnecessary. You can keep a minimal version:

node_modules

Clean up .gitignore

This is the current standard .gitignore:

node_modules
npm-debug.log
coverage
dist/
packages/
/.turbo
/turbo.json
/*.tgz

### i18n ###
src/i18n/transifex_input.json
src/i18n/messages.ts
src/i18n/messages/

### Editors ###
.DS_Store
*~
/temp
/.vscode

Add a Type Declaration file (app.d.ts)

Create an app.d.ts file in the root of your MFE with the following contents:

/// <reference types="@openedx/frontend-base" />

declare module 'site.config' {
  export default SiteConfig;
}

declare module '*.svg' {
  const content: string;
  export default content;
}

Add a tsconfig JSON file

Create a tsconfig.json file and add the following contents to it:

{
  "extends": "@openedx/frontend-base/tools/tsconfig.json",
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "dist",
    "paths": {
      "@src/*": ["./src/*"]
    }
  },
  "include": [
    "src/**/*",
    "app.d.ts",
    "babel.config.js",
    "eslint.config.js",
    "jest.config.js",
    "site.config.*.tsx",
  ],
}

This assumes you have a src folder and your build goes in dist, which is the best practice.

The @src path alias

The paths configuration above sets up the @src alias, which allows you to import from your app's src directory using @src/... instead of relative paths. For example:

// Instead of:
import { MyComponent } from '../../../components/MyComponent';

// You can use:
import { MyComponent } from '@src/components/MyComponent';

For this to work, the app must define its own @src path mapping in tsconfig.json. tsc-alias will then rewrite @src imports to relative paths during the build step, so the compiled JavaScript has proper paths.

Add a tsconfig.build.json file

Create a tsconfig.build.json file for compiling your app before publishing. It will:

  • Extend your main tsconfig.json
  • Output compiled JavaScript and type declarations to dist/
  • Exclude test files and mocks from the published package
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "noEmit": false
  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "src/**/*.test.ts",
    "src/**/*.test.tsx",
    "src/**/*.spec.ts",
    "src/**/*.spec.tsx",
    "src/__mocks__/**/*",
    "src/setupTest.js"
  ]
}

Edit jest.config.js

Replace the import from 'frontend-build' with 'frontend-base'.

- const { createConfig } = require('@openedx/frontend-build');
+ const { createConfig } = require('@openedx/frontend-base/tools');

Use 'test' instead of 'jest' as the config type for createConfig()

module.exports = createConfig('test', {
  // ... custom config
})

Jest test suites that test React components that import SVG and other assets (such as PNGs) must add mocks for those filetypes. This can be accomplished by adding module name mappers to jest.config.js. Just make sure they come before the @src alias, which must also be added here if you're using it:

moduleNameMapper: {
  '\\.svg$': '<rootDir>/src/__mocks__/svg.js',
  '\\.png$': '<rootDir>/src/__mocks__/file.js',
  '^@src/(.*)$': '<rootDir>/src/$1',
},

Then, create a src/__mocks__ folder and add the necessary mocks.

svg.js:

module.exports = 'SvgURL';

file.js:

module.exports = 'FileMock';

You can change the values of "SvgURL", and "FileMock" if you want to reduce changes necessary to your snapshot tests; the old values from frontend-build assume svg is only being used for icons, so the values referenced an "icon" which felt unnecessarily narrow.

This is necessary because we cannot write a tsconfig.json in frontend apps that includes transpilation of the "tools/jest" folder in frontend-base, it can't meaningfully find those files and transpile them, and we wouldn't want all apps to have to include such idiosyncratic configuration anyway. The SVG mock, however, requires ESModules syntax to export its default and ReactComponent exports at the same time. This means without moving the mocks into the app code, the SVG one breaks transpilation and doesn't understand the export syntax used. By moving them into the app, they can be easily transpiled along with all the other code when jest tries to run.

Resulting jest.config.js file

An uncustomized jest.config.js looks like:

const { createConfig } = require('@openedx/frontend-base/tools');

module.exports = createConfig('test', {
  // setupFilesAfterEnv is used after the jest environment has been loaded.  In general this is what you want.
  // If you want to add config BEFORE jest loads, use setupFiles instead.
  setupFilesAfterEnv: [
    '<rootDir>/src/setupTest.js',
  ],
  coveragePathIgnorePatterns: [
    'src/setupTest.js',
    'src/i18n',
    'src/__mocks__',
  ],
  moduleNameMapper: {
    '\\.svg$': '<rootDir>/src/__mocks__/svg.js',
    '\\.png$': '<rootDir>/src/__mocks__/file.js',
    '^@src/(.*)$': '<rootDir>/src/$1',
  },
});

Add a babel.config.js file for Jest

Jest needs a babel.config.js file to be present in the repository. It should look like:

const { createConfig } = require('@openedx/frontend-base/tools');

module.exports = createConfig('babel');

Merge site.config into config in setupTest.js

frontend-platform used environment variables to seed the configuration object, meaning it had default values at the time code is loaded based on process.env variables. frontend-base has a hard-coded, minimal configuration object that must be augmented by a valid site config file at initialization time. This means that any tests that rely on configuration (e.g., via getSiteConfig()) must first initialize the configuration object. This can be done for tests by adding these lines to setupTest.js:

import siteConfig from 'site.config';
import { mergeSiteConfig } from '@openedx/frontend-base';

mergeSiteConfig(siteConfig);

Replace .eslintrc.js with eslint.config.js

ESLint has been upgraded to v9, which has a new 'flat' file format. Replace the repository's .eslintrc.js file with a new eslint.config.js file with the following contents:

// @ts-check

const { createLintConfig } = require('@openedx/frontend-base/tools');

module.exports = createLintConfig(
  {
    files: [
      'src/**/*',
      'site.config.*',
    ],
  },
);

Replace .eslintignore, if it exists, with entries in eslint.config.js

The base eslint config provided by frontend-base ignores a number of common folders by default:

  {
    ignores: [
      'coverage/*',
      'dist/*',
      'node_modules/*',
      '**/__mocks__/*',
      '**/__snapshots__/*',
    ],
  },

You can configure additional ignores in your own eslint.config.js file using the above syntax, as a separate object from the existing 'files' object:

module.exports = createLintConfig(
  {
    files: [
      'src/**/*',
      'site.config.*',
    ],
  },
+  {
+    ignores: [
+      'ignoredfolder/*'
+    ]
+  }
);

Search for any other usages of frontend-build

Find any other imports/usages of frontend-build in your repository and replace them with frontend-base so they don't break.

i18n

Description fields are now required on all i18n messages in the repository. This is because of a change to the ESLint config.

Translations are now pulled and prepared using the openedx translations:pull CLI command. Add an atlasTranslations field to your package.json so the command knows where to find your app's translations and which dependencies to resolve transitively:

"atlasTranslations": {
  "path": "translations/frontend-app-[YOUR_APP]/src/i18n/messages",
  "dependencies": ["@openedx/frontend-base"]
}

Also add a translations:pull script to your package.json:

"scripts": {
  "translations:pull": "openedx translations:pull"
}

And update your pull_translations Makefile target to use it:

pull_translations: | requirements
	npm run translations:pull -- --atlas-options="$(ATLAS_OPTIONS)"

Running npm run translations:pull will pull translations from openedx-translations and generate src/i18n/messages.ts.

Add a src/i18n/index.ts file that re-exports the generated messages:

export { default } from './messages';

Also add a src/i18n/messages.d.ts type declaration file so TypeScript knows the shape of the generated module even before translations:pull has been run:

import type { SiteMessages } from '@openedx/frontend-base';

declare const messages: SiteMessages;
export default messages;

SVGR "ReactComponent" imports have been removed

We have removed the @svgr/webpack loader because it was incompatible with more modern tooling (it requires Babel). As a result, the ability to import SVG files into JS as the ReactComponent export no longer works. We know of a total of 5 places where this is happening today in Open edX MFEs - frontend-app-learning and frontend-app-profile use it. Please replace that export with the default URL export and set the URL as the source of an <img> tag, rather than using ReactComponent. You can see an example of normal SVG imports in test-site/src/ExamplePage.tsx.

Import createConfig and getBaseConfig from @openedx/frontend-base/tools

In frontend-build, createConfig and getBaseConfig could be imported from the root package (@openedx/frontend-build). They have been moved to a sub-directory to make room for runtime exports from the root package (@openedx/frontend-base).

- const { createConfig, getBaseConfig } = require('@openedx/frontend-build');
+ const { createConfig, getBaseConfig } = require('@openedx/frontend-base/tools');

You may have handled this in steps 4 and 5 above (jest.config.js and .eslintrc.js)

Replace all imports from @edx/frontend-platform with @openedx/frontend-base

frontend-base includes all exports from frontend-platform. Rather than export them from sub-directories, it exports them all from the root package folder. As an example:

- import { getConfig } from '@edx/frontend-platform/config';
- import { logInfo } from '@edx/frontend-platform/logging';
- import { FormattedMessage } from '@edx/frontend-platform/i18n';
+ import {
+   getSiteConfig,
+   logInfo,
+   FormattedMessage
+ } from '@openedx/frontend-base';

Note that the configure functions for auth, logging, and analytics are now exported with the names:

  • configureAuth
  • configureLogging
  • configureAnalytics
  • configureI18n

Remember to make the following substitution for these functions:

- import { configure as configureLogging } from '@openedx/frontend-platform/logging';
+ import { configureLogging } from '@openedx/frontend-base';

Finally:

  • Replace all instances of AppProvider with SiteProvider

Dealing with jest.mock of @edx/frontend-platform

You may find that your test suite explicitly mocks parts of frontend-platform; this is usually done by sub-folder, and sometimes replaces the import with mocked versions of some functions:

jest.mock('@edx/frontend-platform/auth', () => ({
  getAuthenticatedUser: jest.fn(),
  fetchAuthenticatedUser: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
  logInfo: jest.fn(),
}));

This is problematic now that all imports from frontend-base are from the root package folder, as it means you'll be mocking much more than you intended:

jest.mock('@openedx/frontend-base', () => ({
  getAuthenticatedUser: jest.fn(),
  fetchAuthenticatedUser: jest.fn(),
  logInfo: jest.fn(),
}));

The above will mock everything but those three functions. This will likely break many tests.

To get around this, consider patching the bulk of the implementations back into your mock via jest.requireActual:

jest.mock('@openedx/frontend-base', () => ({
+  ...jest.requireActual('@openedx/frontend-base'),
  getAuthenticatedUser: jest.fn(),
  fetchAuthenticatedUser: jest.fn(),
  logInfo: jest.fn(),
}));

In this case, the default implementations of most frontend-base exports are included, and only the three afterward are mocked. In most cases, this should work. If you have a more complicated mocking situation in your test, you may need to refactor the test.

Delete the .env and .env.development files and create site.config files.

Frontend-base uses site.config.*.tsx files for configuration, rather than .env files. The development file is site.config.dev.tsx, and the test file is site.config.test.tsx; these are the only ones that needs to be included in the app.

Site config is a new schema for configuration. Notably, config variables are camelCased like normal JavaScript variables, rather than SCREAMING_SNAKE_CASE.

Required config

The required configuration at the time of this writing is:

  • siteId: string
  • siteName: string
  • baseUrl: string
  • lmsBaseUrl: string
  • loginUrl: string
  • logoutUrl: string

Optional config

Other configuration is now optional, and many values have been given sensible defaults. But these configuration variables are also available (as of this writing):

  • environment: EnvironmentTypes
  • basename: string
  • runtimeConfigJsonUrl: string | null
  • accessTokenCookieName: string
  • languagePreferenceCookieName: string
  • userInfoCookieName: string
  • csrfTokenApiPath: string
  • refreshAccessTokenApiPath: string
  • ignoredErrorRegex: RegExp | null
  • segmentKey: string | null

URL Config changes

Note that the .env files and env.config.js files also include a number of URLs for various micro-frontends and services. These URLs should now be expressed as part of the apps config as route roles, and used in code via getUrlForRouteRole(). Or as externalRoutes.

// Creating a route role with for 'example' in an App
const app: App = {
  ...
  routes: [{
    path: '/example',
    id: 'example.page',
    Component: ExamplePage,
    handle: {
      roles: ['example']
    }
  }],
};

// Using the role in code to link to the page
const examplePageUrl = getUrlForRouteRole('example');

App-specific config values

App-specific configuration can be expressed by adding an config section to the app, allowing arbitrary variables:

const app: App = {
  ...
  config: {
    myCustomVariableName: 'my custom variable value',
  },
};

These variables can be used in code with the getAppConfig function:

getAppConfig('myapp').myCustomVariableName

Or via useAppConfig() (with no need to specify the appId), if CurrentAppProvider is wrapping your app.

Complete examples

Refer to the frontend-base branch in frontend-template-application for complete examples of site.config.dev.tsx and site.config.test.tsx.

src file structure

Observe the following file and directory structure. Not counting any extra files the MFE needs, this is what the src directory should look like for all frontend apps in the Open edX org:

src
(...)
├── sass
├── slots
├── widgets
├── Main.jsx
├── app.ts
├── constants.ts
├── index.ts
├── messages.js
├── providers.ts
├── routes.tsx
├── setupTest.tsx
├── slots.tsx
└── style.scss

A brief explanation of the new ones:

  • slots: renamed from plugin-slots
  • widgets: where any built-in widgets should be created
  • Main.jsx: the spiritual successor to index.jsx: where the root component is defined, including an <Outlet /> if the main route has children
  • constants.ts: should contain an export of the app's appId
  • index.ts: the MFE is now a library, and this is where all the interesting bits are exported; this file should only contain exports, no react components
  • app.ts: the app configuration that will be imported by site.config files
  • providers.ts: where global context providers are defined
  • routes.tsx: where the app's routes are declared
  • slots.tsx: what slots the app uses; this is distinct from the slots the app offers, which are defined in the slots directory
  • style.scss: app-scoped runtime styles, imported from component code (an internal implementation detail, not exported)
  • sass: partials used by style.scss, if any

Create, rename, and/or move file contents around to match. Refer to a previously converted MFE (such as Learner Dashboard) for examples.

Replace the .env.test file with configuration in site.config.test.tsx file

We're moving away from .env files because they're not expressive enough (only string types!) to configure an Open edX frontend. Instead, the test suite has been configured to expect a site.config.test.tsx file. If you're initializing an application in your tests, frontend-base will pick up this configuration and make it available to getSiteConfig(), etc. If you need to manually access the variables, you can import site.config in your test files:

+ import siteConfig from 'site.config';

The Jest configuration has been set up to find site.config in a site.config.test.tsx file.

Once you've verified your test suite still works, you should delete the .env.test file.

A sample site.config.test.tsx file:

import type { SiteConfig } from '@openedx/frontend-base';

const siteConfig: SiteConfig = {
  siteId: 'test',
  siteName: 'localhost',
  baseUrl: 'http://localhost:8080',
  lmsBaseUrl: 'http://localhost:18000',
  loginUrl: 'http://localhost:18000/login',
  logoutUrl: 'http://localhost:18000/logout',
  // Use 'test' instead of EnvironmentTypes.TEST to break a circular dependency
  // when mocking `@openedx/frontend-base` itself.
  environment: 'test' as SiteConfig['environment'],
  apps: [{
    appId: 'test-app',
    routes: [{
      path: '/app1',
      element: (
        <div>Test App 1</div>
      ),
      handle: {
        roles: ['test-app-1']
      }
    }]
  }],
  accessTokenCookieName: 'edx-jwt-cookie-header-payload',
  csrfTokenApiPath: '/csrf/api/v1/token',
  languagePreferenceCookieName: 'openedx-language-preference',
  refreshAccessTokenApiPath: '/login_refresh',
  userInfoCookieName: 'edx-user-info',
  ignoredErrorRegex: null,
};

export default siteConfig;

Remove initialization

In your index.(jsx|tsx) file, you need to remove the subscribe and initialization code. If you have customizations here, they will need to migrate to your site.config file instead and take advantage of the shell's provided customization mechanisms.

Migrate header/footer dependencies

If your application uses a custom header or footer, you can use the shell's header and footer plugin slots to provide your custom header/footer components. This is done through the site.config file.

Export the modules of your app in your index.ts file.

This may require a little interpretation. In spirit, the modules of your app are the 'pages' of an Open edX Frontend site that it provides. This likely corresponds to the top-level react-router routes in your app. In frontend-app-profile, for instance, this is the ProfilePage component, amongst a few others. Some MFEs have put their router and pages directly into the index.jsx file inside the initialization callback - this code will need to be moved to a single component that can be exported.

These modules should be unopinionated about the path prefix where they are mounted.

Set the document title on every route-level page

A frontend-base site renders all of its apps inside a single index.html, so the document title only changes if a page explicitly updates it. If your app doesn't set a title, the browser tab keeps whatever the previously rendered app set.

Each route-level page component must therefore set the document title using <Helmet> from react-helmet. In practice this is the small wrapper component the route lazy-loads (typically Main), not the inner content component or any shared layout, slot, or nested widget. The pattern is:

  1. Add react-helmet to the app's dependencies if it isn't there already.
  2. Add a localized message per page, with the id pattern {page}.page.title and a default of {Page Name} | {siteName}. Don't reuse an existing on-page heading message (e.g., the h1/h2 "page title" used in the body): the document title needs {siteName} interpolation that would be wrong on a heading.
  3. Render a <Helmet> block in the route entry that sets <title> from that message, passing siteName from getSiteConfig().siteName as an i18n parameter.
// src/Main.jsx — the component the route lazy-loads
import { CurrentAppProvider, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Helmet } from 'react-helmet';

import { appId } from './constants';
import messages from './messages';
import Dashboard from './containers/Dashboard';

const Main = () => {
  const { formatMessage } = useIntl();
  return (
    <CurrentAppProvider appId={appId}>
      <Helmet>
        <title>
          {formatMessage(messages['learner.dashboard.page.title'], {
            siteName: getSiteConfig().siteName,
          })}
        </title>
      </Helmet>
      <Dashboard />
    </CurrentAppProvider>
  );
};

export default Main;
// src/messages.js
import { defineMessages } from '@openedx/frontend-base';

export default defineMessages({
  'learner.dashboard.page.title': {
    id: 'learner.dashboard.page.title',
    defaultMessage: 'Dashboard | {siteName}',
    description: 'document title for the learner dashboard',
  },
});

If the app has nested child routes (for example, a parent route with tabbed sub-routes under it), set the title once at the parent route entry. Per-child titles are optional and follow the same pattern in each child component.

Pages with dynamic titles (for example, a course page that reads {Course Name} | {siteName}) follow the same pattern: render <Helmet> once the data is available and pass the dynamic value through formatMessage. Until the data resolves, the previous page's title persists, which is acceptable for the brief loading window.

See ADR 0015 for the full rationale and rejected alternatives.

Separate runtime styles from the dev harness

Frontend apps deal with two distinct sets of styles, and the distinction matters for the styling of the composing site:

  1. Runtime styles: App-scoped SCSS that lives inside src/ and is imported from the app's component code. It is an internal implementation detail of the package, never exposed through the exports map, and loaded automatically whenever the app's code runs (including as a lazy-loaded route in a larger site).
  2. Dev harness imports: The shell's style manifest (@openedx/frontend-base/shell/style) loads Paragon's base CSS and the shell's own styles. It is imported only from site.config.dev.tsx, so it runs when the app is run standalone for development.

Important

Runtime styles must NOT import @openedx/frontend-base/shell/style (or any other source of Paragon base styles). Paragon's base styles set CSS custom properties at :root. If a lazy-loaded app chunk re-injects those declarations, they clobber any brand overrides that the composing site has already applied, breaking theming globally. See the theming guide for details.

Runtime styles

Keep app-scoped SCSS under src/ and import it directly from the component code that needs it. The suggested layout is a single src/style.scss entry point, with any partials under src/sass/:

import './style.scss';

Any file layout works, as long as the styles are loaded by the app's own JS/TS modules rather than exposed to consumers.

Important

Any SCSS entry that uses @media (--pgn-size-breakpoint-*) must @use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css" at the top. That includes src/style.scss and every component-level index.scss imported directly from JS: each is its own PostCSS pass and does not inherit the declarations from siblings. Missing @uses fail silently (the rule never matches any viewport). See the theming guide for the full rationale.

Dev harness imports

Import the shell's style manifest from site.config.dev.tsx (NOT from site.config.build.tsx or any file that ships). This loads the global styles your app needs when it runs standalone:

+ import '@openedx/frontend-base/shell/style';

const siteConfig: SiteConfig = {
  // config document
}

export default siteConfig;

Dev harnesses should NOT import brand packages: running against unbranded Paragon defaults surfaces styling bugs that brand overrides would otherwise hide, and it keeps brand assets out of the app's dependency tree entirely.

Document module-specific configuration needs

Your modules will need environment variables that your system merged into config in index.jsx - we need to document and expect those when the module is loaded. You'll need this list in the next step.

Stop using process.env

Instead, custom variables must go through site config. This can be done by adding a 'config' object to the App's definition

Convert @import to @use in SCSS files

@import is deprecated in the most recent versions of SASS. It must be converted to @use.

If still importing Paragon SCSS variables, you will find that they, in particular, are likely to result in errors when building the app in webpack. The app should be migrated to use CSS variables from Paragon 23, as per the corresponding howto.

Changes to i18n

configureI18n no longer takes config or loggingService as options

The getLoggingService export from i18n has also been removed. No one should be using that.

getLanguageList has been removed. Modules that need a list of countries should install @cospired/i18n-iso-languages as a dependency.

getSupportedLanguageList now returns an array of objects containing the name and code of all the languages that have translations bundled with the app, rather than a hard-coded list.

getCountryList has been removed. MFEs that need a list of countries should install i18n-iso-countries or countries-list as a dependency.

The getCountryList function can be reproduced from this file in frontend-platform: https://github.com/openedx/frontend-platform/blob/master/src/i18n/countries.js

frontend-app-account should use the supported language list from frontend-base, rather than the hard-coded list in https://github.com/openedx/frontend-app-account/blob/master/src/account-settings/site-language/constants.js

This would help it match the behavior of the footer's language dropdown.

react-query

If the MFE uses react-query version 4 or below, upgrade it to 5 as per this guide. Also remove any instances of <QueryClientProvider />, as the shell already provides a global one.

If the MFE uses Redux, consider porting the app over to react-query, as it will make it much easier to handle header (and footer) customization.

Removal of pubsub-js

frontend-platform used pubsub-js behind the scenes for event subscriptions/publishing. It used it in a very rudimentary way, and the library was noisy in test suites, complaining about being re-initialized. Because of these reasons, we've removed our dependency on pubsub-js and replaced it with a simple subscription system with a very similar API:

  • subscribe(topic: string, callback: (topic: string, data?: any) => void)
  • publish(topic: string, data?: any)
  • unsubscribe(topic: string, callback: (topic: string, data?: any) => void)
  • clearAllSubscriptions()

The unsubscribe function as a different API than pubsub-js's unsubscribe function, taking a topic and a callback rather than an unsubscribe token.

Consumers who were using the PubSub global variable should instead import the above functions directly from @openedx/frontend-base.

Refactor slots

First, rename src/plugin-slots, if it exists, to src/slots. Modify imports and documentation across the codebase accordingly.

Next, the frontend-base equivalent to <PluginSlot /> is <Slot />, and has a different API. This includes a change in the slot ID, according to the new slot naming ADR in this repository. Rename them accordingly. You can refer to the src/shell/dev in this repository for examples.

Set up npm workspaces for local development

Frontend apps support npm workspaces <https://docs.npmjs.com/cli/using-npm/workspaces>_ so that developers can work on the app and its dependencies (such as frontend-base) simultaneously, with changes reflected automatically.

Add the workspaces field to package.json

+ "workspaces": [
+   "packages/*"
+ ],

This tells npm to look in packages/ for local overrides of published packages. The packages/ directory is gitignored (see the .gitignore step above), since it contains development-only bind-mounted checkouts.

Add a turbo.site.json file

Create a turbo.site.json at the repository root. This configures Turborepo <https://turbo.build/>_ to build workspace packages in dependency order and run persistent tasks (watch and dev server) concurrently:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "cache": false
    },
    "clean": {
      "cache": false
    },
    "watch:build": {
      "dependsOn": ["^build"],
      "persistent": true,
      "cache": false
    },
    "//#dev:site": {
      "dependsOn": ["^build"],
      "persistent": true,
      "cache": false
    }
  }
}

The file is named turbo.site.json rather than turbo.json to avoid conflicts with turbo v2's workspace validation. When a site repository includes your app as an npm workspace, turbo scans for turbo.json in each package directory and rejects root task syntax (//#) and configs without "extends". By using a different filename, the config is invisible to turbo during workspace runs, and only activated via the Makefile when running standalone (see below).

Add a nodemon.json file

Create a nodemon.json at the repository root. This configures the watch:build script to rebuild automatically when source files change:

{
  "watch": [
    "src"
  ],
  "ext": "js,jsx,ts,tsx,scss"
}

Add workspace-aware scripts

Install turbo and nodemon as dev dependencies:

npm install --save-dev turbo nodemon

Then add the following scripts to package.json:

"build:packages": "make build-packages",
"clean:packages": "make clean-packages",
"dev:site": "make dev-site",
"dev:packages": "make dev-packages",
"watch:build": "nodemon --exec 'npm run build'",

And add the corresponding Makefile targets:

TURBO = TURBO_TELEMETRY_DISABLED=1 turbo --dangerously-disable-package-manager-check

# turbo.site.json is the standalone turbo config for this package.  It is
# renamed to avoid conflicts with turbo v2's workspace validation, which
# rejects root task syntax (//#) and requires "extends" in package-level
# turbo.json files, such as when running in a site repository. The targets
# below copy it into place before running turbo and clean up after.
turbo.json: turbo.site.json
	cp $< $@

# NPM doesn't bin-link workspace packages during install, so it must be done manually.
bin-link:
	[ -f packages/frontend-base/package.json ] && npm rebuild --ignore-scripts @openedx/frontend-base || true

build-packages: turbo.json
	$(TURBO) run build; rm -f turbo.json
	$(MAKE) bin-link

clean-packages: turbo.json
	$(TURBO) run clean; rm -f turbo.json

dev-packages: build-packages turbo.json
	$(TURBO) run watch:build dev:site; rm -f turbo.json

dev-site: bin-link
	npm run dev
  • watch:build uses nodemon to watch for source changes (as configured in nodemon.json) and re-runs npm run build on each change. Turbo runs this in each workspace package that defines it.
  • build:packages builds all workspace packages in dependency order (e.g., frontend-base before the app), then runs make bin-link to create missing bin links. This is necessary because npm skips bin-linking for workspace packages during install, so without this step the openedx CLI won't be available in node_modules/.bin.
  • clean:packages runs the clean script in each workspace package.
  • dev:site is an alias for npm run dev that also bin-links the frontend-base bin files; turbo uses it as a root-only task (//#dev:site).
  • dev:packages depends on build-packages so the CLI is available before starting the watch, then concurrently watches workspace packages for changes and starts the dev server.

The Makefile targets copy turbo.site.json to turbo.json before invoking turbo, then remove the copy afterward. This ensures turbo finds its expected config when running standalone, without leaving a turbo.json that would conflict in a workspace context. The --dangerously-disable-package-manager-check flag and TURBO_TELEMETRY_DISABLED=1 are also set here, keeping turbo invocation details in one place.

Using workspaces

To develop against a local frontend-base:

mkdir -p packages/frontend-base
sudo mount --bind /path/to/frontend-base packages/frontend-base
npm install
npm run dev:packages

Bind mounts are used instead of symlinks because Node.js resolves symlinks to real paths, which breaks hoisted dependency resolution. Docker volume mounts work equally well (and are what tutor dev uses).

When done, unmount with:

sudo umount packages/frontend-base