Skip to content

Commit

Permalink
feat: Getting the react-router data router and federated modules work…
Browse files Browse the repository at this point in the history
…ing!

This lets the shell load federated modules and then use the “fog of war” API in react-router to add their routes to the router on demand.  It works!
  • Loading branch information
davidjoy committed Sep 11, 2024
1 parent 6004f03 commit 06213ba
Show file tree
Hide file tree
Showing 12 changed files with 408 additions and 272 deletions.
5 changes: 4 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,17 @@ export {
} from './runtime';

export type {
ApplicationModuleConfig,
ExternalAppConfig,
FederatedAppConfig,
InsertDirectPluginWidget,
InsertIframePluginWidget,
InternalAppConfig,
ModuleConfig,
ProjectSiteConfig
} from './types';

export {
PluginOperations,
AppConfigTypes, HeaderTypes, PluginOperations,
PluginTypes
} from './types';
398 changes: 239 additions & 159 deletions package-lock.json

Large diffs are not rendered by default.

44 changes: 0 additions & 44 deletions shell/FederatedComponent.tsx

This file was deleted.

25 changes: 25 additions & 0 deletions shell/Shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Suspense } from 'react';
import { Outlet } from 'react-router';
import {
AppProvider,
PluginSlot
} from '../runtime';
import Footer from './footer';
import ActiveHeader from './header/ActiveHeader';

export default function Shell() {
// TODO: Plugin Slots for header/footer should be per type, i.e., default, learning, and studio slots.
return (
<AppProvider>
<PluginSlot id="org.openedx.frontend.shell.header.v1">
<ActiveHeader />
</PluginSlot>
<Suspense fallback={<div>Loading</div>}>
<Outlet />
</Suspense>
<PluginSlot id="org.openedx.frontend.shell.footer.v1">
<Footer />
</PluginSlot>
</AppProvider>
);
}
70 changes: 11 additions & 59 deletions shell/bootstrap.tsx
Original file line number Diff line number Diff line change
@@ -1,78 +1,30 @@
import { init } from '@module-federation/runtime';
import ReactDOM from 'react-dom';
import { RouterProvider } from 'react-router-dom';

import { Container } from '@openedx/paragon';
import { Route, Routes } from 'react-router';
import {
APP_INIT_ERROR, APP_READY,
AppProvider,
getConfig,
APP_INIT_ERROR,
APP_READY,
initialize,
subscribe
} from '../runtime';
import { ExternalAppConfig, FederatedAppConfig, InternalAppConfig } from '../types';
import FederatedComponent from './FederatedComponent';
import Footer from './footer';
import { DefaultHeader } from './header';

const messages = [];

function getFederatedApps() {
const { apps } = getConfig();

return apps.filter((app: InternalAppConfig | ExternalAppConfig | FederatedAppConfig) => 'remoteUrl' in app && 'appId' in app);
}

function getFederationRemotes(apps) {
return apps.map(app => ({
name: app.appId,
entry: app.remoteUrl
}));
}
import { SHELL_ID } from './data/constants';
import { getFederationRemotes } from './data/moduleUtils';
import createRouter from './router/createRouter';

function getInternalApps(): Array<InternalAppConfig> {
const { apps } = getConfig();

return apps.filter((app: InternalAppConfig | ExternalAppConfig | FederatedAppConfig) => 'component' in app);
}
const messages = [];

subscribe(APP_READY, () => {
const federatedApps = getFederatedApps();
const remotes = getFederationRemotes(federatedApps);

init({
name: 'shell',
remotes,
name: SHELL_ID,
remotes: getFederationRemotes(),
});

const internalApps = getInternalApps();
const router = createRouter();

ReactDOM.render(
<AppProvider wrapWithRouter>
<DefaultHeader />
<Container className="m-2">
<Routes>
{internalApps.map((internalApp: InternalAppConfig) => {
const AppComponent = internalApp.component;
return (
<Route
key={`${internalApp.appId}-${internalApp.path}`}
path={internalApp.path}
element={<AppComponent />}
/>
);
})}
{federatedApps.map((federatedApp: FederatedAppConfig) => (
<Route
key={`${federatedApp.appId}-${federatedApp.moduleId}`}
path={federatedApp.path}
element={<FederatedComponent federatedApp={federatedApp} />}
/>
))}
</Routes>
</Container>
<Footer />
</AppProvider>,
<RouterProvider router={router} />,
document.getElementById('root'),
);
});
Expand Down
2 changes: 2 additions & 0 deletions shell/data/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const SHELL_ID = 'shell';
41 changes: 41 additions & 0 deletions shell/data/moduleUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { loadRemote } from '@module-federation/runtime';
import { getConfig } from '../../runtime';
import {
AppConfig, AppConfigTypes, ApplicationModuleConfig,
InternalAppConfig
} from '../../types';

export function getFederatedModules() {
const { apps } = getConfig();

return apps.filter((app: AppConfig) => app.type === AppConfigTypes.FEDERATED);
}

export function getFederationRemotes() {
const federatedModules = getFederatedModules();
return federatedModules.map(app => ({
name: app.appId,
entry: app.remoteUrl
}));
}

export async function loadModuleConfig(module, scope) {
let config:ApplicationModuleConfig | null = null;
try {
const loadedRemote = await loadRemote<{ default: ApplicationModuleConfig }>(`${scope}/${module}`);
if (loadedRemote !== null) {
config = loadedRemote.default;
}
} catch (error) {
console.error(`Error loading remote module ${scope}/${module}:`, error);
}
return config;
}

export function getInternalModules(): Array<InternalAppConfig> {
const { apps } = getConfig();

const internalModules = apps.filter((app: AppConfig) => app.type === AppConfigTypes.INTERNAL);

return internalModules as Array<InternalAppConfig>;
}
16 changes: 16 additions & 0 deletions shell/router/createInternalRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { RouteObject } from 'react-router';

import { InternalAppConfig } from '../../types';
import { getInternalModules } from '../data/moduleUtils';

export default function createInternalRoutes() {
const internalModules = getInternalModules();

let routes: Array<RouteObject> = [];

internalModules.forEach((internalModule: InternalAppConfig) => {
const moduleRoutes = internalModule.config.routes;
routes = [...routes, ...moduleRoutes];
});
return routes;
}
20 changes: 20 additions & 0 deletions shell/router/createRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createBrowserRouter } from 'react-router-dom';

import { getBasename } from '../../runtime/initialize';
import { SHELL_ID } from '../data/constants';
import Shell from '../Shell';
import createInternalRoutes from './createInternalRoutes';
import patchRoutesOnNavigation from './patchRoutesOnNavigation';

export default function createRouter() {
return createBrowserRouter([
{
id: SHELL_ID,
Component: Shell,
children: createInternalRoutes(),
}
], {
basename: getBasename(),
unstable_patchRoutesOnNavigation: patchRoutesOnNavigation,
});
}
24 changes: 24 additions & 0 deletions shell/router/patchRoutesOnNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { FederatedAppConfig } from '../../types';
import { SHELL_ID } from '../data/constants';
import { getFederatedModules, loadModuleConfig } from '../data/moduleUtils';

export default async function patchRoutesOnNavigation({ path, patch }) {
const federatedModules = getFederatedModules();
let missingModule: FederatedAppConfig | null = null;
for (let i = 0; i < federatedModules.length; i++) {
const federatedModule = federatedModules[i];
if (path.startsWith(federatedModule.path)) {
missingModule = federatedModule;
break;
}
}

if (missingModule) {
const moduleConfig = await loadModuleConfig(missingModule.moduleId, missingModule.appId);
if (moduleConfig) {
patch(SHELL_ID, moduleConfig.routes);
} else {
console.log('uhoh, no module config.');
}
}
}
2 changes: 1 addition & 1 deletion test-project/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 25 additions & 8 deletions types.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,38 @@
import { ElementType } from 'react';
import { RouteObject } from 'react-router';

export type AppConfig = ExternalAppConfig | InternalAppConfig | FederatedAppConfig;

export enum AppConfigTypes {
EXTERNAL = 'external',
INTERNAL = 'internal',
FEDERATED = 'federated',
}

export interface ExternalAppConfig {
type: AppConfigTypes.EXTERNAL,
appId: string,
moduleId: string,
url: string,
}

export interface ApplicationModuleConfig {
routes: Array<RouteObject>
}

export interface InternalAppConfig {
type: AppConfigTypes.INTERNAL,
appId: string,
component: ElementType,
path: string,
config?: {
[key: string]: any,
}
config: ApplicationModuleConfig,
path?: string,
}

export interface FederatedAppConfig {
type: AppConfigTypes.FEDERATED,
appId: string,
remoteUrl: string,
moduleId: string,
path: string,
config?: {
[key: string]: any,
}
}
/**
* Defines the changes to be made to either the default widget(s) or to any
Expand Down Expand Up @@ -182,3 +193,9 @@ export interface User {
roles: Array<string>,
administrator: boolean,
}

export enum HeaderTypes {
DEFAULT = 'default',
STUDIO = 'studio',
LEARNING = 'learning',
}

0 comments on commit 06213ba

Please sign in to comment.