Initial implementation of frontend plugins

Uses webpack 5 module federation to dynamically load code based on a JS file URL.
This commit is contained in:
David Joy
2021-02-09 17:21:34 -05:00
parent 3a7c455bb3
commit 76a85224b8
5 changed files with 2920 additions and 5232 deletions

7961
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -62,7 +62,7 @@
"truncate-html": "1.0.3"
},
"devDependencies": {
"@edx/frontend-build": "5.5.5",
"@edx/frontend-build": "git+https://github.com/edx/frontend-build.git#alpha",
"@testing-library/dom": "7.16.3",
"@testing-library/jest-dom": "5.10.1",
"@testing-library/react": "10.3.0",

View File

@@ -8,7 +8,7 @@ import {
import { AppProvider, ErrorPage, PageRoute } from '@edx/frontend-platform/react';
import React from 'react';
import ReactDOM from 'react-dom';
import { Switch } from 'react-router-dom';
import { Route, Switch } from 'react-router-dom';
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
@@ -27,12 +27,14 @@ import { TabContainer } from './tab-page';
import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data';
import { fetchCourse } from './courseware/data';
import initializeStore from './store';
import PluginTest from './plugin-test/PluginTest';
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider store={initializeStore()}>
<UserMessagesProvider>
<Switch>
<Route exact path="/" component={PluginTest} />
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
<PageRoute path="/course/:courseId/home">
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">

View File

@@ -0,0 +1,157 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
function loadPluginComponent(scope, module) {
return async () => {
// Initializes the share scope. This fills it with known provided modules from this build and all remotes
// eslint-disable-next-line no-undef
await __webpack_init_sharing__('default');
const container = window[scope]; // or get the container somewhere else
// Initialize the container, it may provide shared modules
// eslint-disable-next-line no-undef
await container.init(__webpack_share_scopes__.default);
const factory = await window[scope].get(module);
const Module = factory();
return Module;
};
}
async function loadDynamicScript(url) {
return new Promise((resolve, reject) => {
const element = document.createElement('script');
element.src = url;
element.type = 'text/javascript';
element.async = true;
element.onload = () => {
// eslint-disable-next-line no-console
console.log(`Dynamic Script Loaded: ${url}`);
resolve();
};
element.onerror = () => {
// eslint-disable-next-line no-console
console.error(`Dynamic Script Error: ${url}`);
reject();
};
document.head.appendChild(element);
});
}
const useDynamicScript = (args) => {
const [ready, setReady] = React.useState(false);
const [failed, setFailed] = React.useState(false);
const [element, setElement] = React.useState(null);
React.useEffect(() => {
if (!args.url) {
return;
}
setReady(false);
setFailed(false);
loadDynamicScript(args.url).then((el) => {
setElement(el);
setReady(true);
}).catch(() => {
setReady(false);
setFailed(true);
});
// eslint-disable-next-line consistent-return
return () => {
// eslint-disable-next-line no-console
console.log(`Dynamic Script Removed: ${args.url}`);
document.head.removeChild(element);
};
}, [args.url]);
return {
ready,
failed,
};
};
function Plugin({ plugin }) {
const { ready, failed } = useDynamicScript({
url: plugin && plugin.url,
});
if (!plugin) {
return <h2>No plugin specified</h2>;
}
if (!ready) {
return <h2>Loading plugin script: {plugin.url}</h2>;
}
if (failed) {
return <h2>Failed to load plugin script: {plugin.url}</h2>;
}
const PluginComponent = React.lazy(
loadPluginComponent(plugin.scope, plugin.module),
);
return (
<React.Suspense fallback="Loading Plugin">
<PluginComponent />
</React.Suspense>
);
}
Plugin.propTypes = {
plugin: PropTypes.shape({
scope: PropTypes.string.isRequired,
module: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
}),
};
Plugin.defaultProps = {
plugin: null,
};
function PluginTest() {
const [plugin, setPlugin] = React.useState(undefined);
function setPluginOne() {
setPlugin({
url: 'http://localhost:7331/remoteEntry.js',
scope: 'plugin',
module: './PluginOne',
});
}
function setPluginTwo() {
setPlugin({
url: 'http://localhost:7331/remoteEntry.js',
scope: 'plugin',
module: './PluginTwo',
});
}
return (
<div>
<h1>Dynamic Plugin Host</h1>
<h2>App 1</h2>
<p>
The Dynamic Plugin will take advantage Module Federation{' '}
<strong>remotes</strong> and <strong>exposes</strong>. It will no load
components that have been loaded already.
</p>
<Button className="mr-3" onClick={setPluginOne}>Load Plugin One</Button>
<Button onClick={setPluginTwo}>Load Plugin Two</Button>
<div style={{ marginTop: '2em' }}>
<Plugin plugin={plugin} />
</div>
</div>
);
}
export default PluginTest;

28
webpack.dev.config.js Normal file
View File

@@ -0,0 +1,28 @@
const { createConfig } = require('@edx/frontend-build');
// eslint-disable-next-line import/no-extraneous-dependencies
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = createConfig('webpack-dev', {
plugins: [
new ModuleFederationPlugin({
name: 'learning',
shared: {
react: {
import: 'react', // the "react" package will be used a provided and fallback module
shareKey: 'react', // under this name the shared module will be placed in the share scope
shareScope: 'default', // share scope with this name will be used
singleton: true, // only a single version of the shared module is allowed
eager: true,
},
'react-dom': {
singleton: true, // only a single version of the shared module is allowed
eager: true,
},
'@edx/frontend-platform': {
singleton: true,
eager: true,
},
},
}),
],
});