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:
7961
package-lock.json
generated
7961
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
157
src/plugin-test/PluginTest.jsx
Normal file
157
src/plugin-test/PluginTest.jsx
Normal 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
28
webpack.dev.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user