From 6da29373d932050189d9052749e3409565cbb9d9 Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 25 Apr 2023 09:56:14 -0400 Subject: [PATCH 01/28] STRIPES-861: Setup federation --- src/init.js => bootstrap.js | 3 +- src/AppRoutes.js | 4 +- src/CalloutContext.js | 4 +- src/ModulesContext.js | 7 +- src/Pluggable.js | 3 +- src/RootWithIntl.js | 7 +- src/StripesContext.js | 4 +- .../LastVisited/LastVisitedContext.js | 4 +- .../MainNav/CurrentApp/AppCtxMenuContext.js | 3 +- .../ModuleHierarchy/ModuleHierarchyContext.js | 4 +- .../ModuleTranslator/ModuleTranslator.js | 48 ------------- src/components/ModuleTranslator/index.js | 1 - src/components/RegistryLoader.js | 69 +++++++++++++++++++ src/components/index.js | 2 - src/loadRemoteComponent.js | 21 ++++++ src/locationService.js | 2 +- src/translateModules.js | 0 17 files changed, 117 insertions(+), 69 deletions(-) rename src/init.js => bootstrap.js (90%) delete mode 100644 src/components/ModuleTranslator/ModuleTranslator.js delete mode 100644 src/components/ModuleTranslator/index.js create mode 100644 src/components/RegistryLoader.js create mode 100644 src/loadRemoteComponent.js delete mode 100644 src/translateModules.js diff --git a/src/init.js b/bootstrap.js similarity index 90% rename from src/init.js rename to bootstrap.js index ebf891cce..2227a0edc 100644 --- a/src/init.js +++ b/bootstrap.js @@ -1,9 +1,10 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; + import React from 'react'; import { createRoot } from 'react-dom/client'; -import App from './App'; +import App from './src/App'; export default function init() { const container = document.getElementById('root'); diff --git a/src/AppRoutes.js b/src/AppRoutes.js index d2cf919b4..e284af4d3 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -11,6 +11,7 @@ import { getEventHandlers } from './handlerService'; import { packageName } from './constants'; import { ModuleHierarchyProvider } from './components'; import events from './events'; +import loadRemoteComponent from './loadRemoteComponent'; // Process and cache "app" type modules and render the routes const AppRoutes = ({ modules, stripes }) => { @@ -22,11 +23,12 @@ const AppRoutes = ({ modules, stripes }) => { const perm = `module.${name}.enabled`; if (!stripes.hasPerm(perm)) return null; + const RemoteComponent = React.lazy(() => loadRemoteComponent(module.url, module.name)); const connect = connectFor(module.module, stripes.epics, stripes.logger); let ModuleComponent; try { - ModuleComponent = connect(module.getModule()); + ModuleComponent = connect(RemoteComponent); } catch (error) { console.error(error); // eslint-disable-line throw Error(error); diff --git a/src/CalloutContext.js b/src/CalloutContext.js index b2b0f180c..66665e70f 100644 --- a/src/CalloutContext.js +++ b/src/CalloutContext.js @@ -1,6 +1,8 @@ import React, { useContext } from 'react'; -export const CalloutContext = React.createContext(); +import { CalloutContext } from '@folio/stripes-shared-context'; + +export { CalloutContext }; export const useCallout = () => { return useContext(CalloutContext); diff --git a/src/ModulesContext.js b/src/ModulesContext.js index 2ffed1213..863526877 100644 --- a/src/ModulesContext.js +++ b/src/ModulesContext.js @@ -1,7 +1,6 @@ -import React, { useContext } from 'react'; -import { modules } from 'stripes-config'; +import { useContext } from 'react'; +import { ModulesContext } from '@folio/stripes-shared-context'; -export const ModulesContext = React.createContext(modules); +export { ModulesContext }; export default ModulesContext; export const useModules = () => useContext(ModulesContext); -export { modules as originalModules }; diff --git a/src/Pluggable.js b/src/Pluggable.js index 6d1a27baa..75c07cd31 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -1,10 +1,11 @@ import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; -import { modules } from 'stripes-config'; +import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; const Pluggable = (props) => { + const modules = useModules(); const plugins = modules.plugin || []; const cachedPlugins = useMemo(() => { const cached = []; diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 1dcf29c63..143387034 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -40,6 +40,7 @@ import StaleBundleWarning from './components/StaleBundleWarning'; import { StripesContext } from './StripesContext'; import { CalloutContext } from './CalloutContext'; import AuthnLogin from './components/AuthnLogin'; +import RegistryLoader from './components/RegistryLoader'; const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAuth, history = {}, queryClient }) => { const connect = connectFor('@folio/core', stripes.epics, stripes.logger); @@ -53,7 +54,8 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut return ( - + + - + + ); diff --git a/src/StripesContext.js b/src/StripesContext.js index 835262268..16c062389 100644 --- a/src/StripesContext.js +++ b/src/StripesContext.js @@ -2,7 +2,9 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import hoistNonReactStatics from 'hoist-non-react-statics'; -export const StripesContext = React.createContext(); +import { StripesContext } from '@folio/stripes-shared-context'; + +export { StripesContext }; function getDisplayName(WrappedComponent) { return WrappedComponent.displayName || WrappedComponent.name || 'Component'; diff --git a/src/components/LastVisited/LastVisitedContext.js b/src/components/LastVisited/LastVisitedContext.js index 166e08b4a..e7c9f0f84 100644 --- a/src/components/LastVisited/LastVisitedContext.js +++ b/src/components/LastVisited/LastVisitedContext.js @@ -1,3 +1,3 @@ -import React from 'react'; +import { LastVisitedContext } from '@folio/stripes-shared-context'; -export default React.createContext({}); +export default LastVisitedContext; diff --git a/src/components/MainNav/CurrentApp/AppCtxMenuContext.js b/src/components/MainNav/CurrentApp/AppCtxMenuContext.js index 791887881..9327f1769 100644 --- a/src/components/MainNav/CurrentApp/AppCtxMenuContext.js +++ b/src/components/MainNav/CurrentApp/AppCtxMenuContext.js @@ -1,7 +1,8 @@ import React from 'react'; import hoistNonReactStatics from 'hoist-non-react-statics'; +import { AppCtxMenuContext } from '@folio/stripes-shared-context'; -export const AppCtxMenuContext = React.createContext(); +export { AppCtxMenuContext }; export function withAppCtxMenu(Component) { const WrappedComponent = (props) => { diff --git a/src/components/ModuleHierarchy/ModuleHierarchyContext.js b/src/components/ModuleHierarchy/ModuleHierarchyContext.js index 265f1101b..1a5c4d177 100644 --- a/src/components/ModuleHierarchy/ModuleHierarchyContext.js +++ b/src/components/ModuleHierarchy/ModuleHierarchyContext.js @@ -1,5 +1,3 @@ -import React from 'react'; - -const ModuleHierarchyContext = React.createContext(); +import { ModuleHierarchyContext } from '@folio/stripes-shared-context'; export default ModuleHierarchyContext; diff --git a/src/components/ModuleTranslator/ModuleTranslator.js b/src/components/ModuleTranslator/ModuleTranslator.js deleted file mode 100644 index 3253cc042..000000000 --- a/src/components/ModuleTranslator/ModuleTranslator.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { injectIntl } from 'react-intl'; - -import { ModulesContext, originalModules } from '../../ModulesContext'; - -class ModuleTranslator extends React.Component { - static propTypes = { - children: PropTypes.node, - intl: PropTypes.object, - } - - constructor(props) { - super(props); - - this.state = { - modules: this.translateModules(), - }; - } - - translateModules = () => { - return { - app: (originalModules.app || []).map(this.translateModule), - plugin: (originalModules.plugin || []).map(this.translateModule), - settings: (originalModules.settings || []).map(this.translateModule), - handler: (originalModules.handler || []).map(this.translateModule), - }; - } - - translateModule = (module) => { - const { formatMessage } = this.props.intl; - - return { - ...module, - displayName: module.displayName ? formatMessage({ id: module.displayName }) : undefined, - }; - } - - render() { - return ( - - { this.props.children } - - ); - } -} - -export default injectIntl(ModuleTranslator); diff --git a/src/components/ModuleTranslator/index.js b/src/components/ModuleTranslator/index.js deleted file mode 100644 index fe476e5a9..000000000 --- a/src/components/ModuleTranslator/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ModuleTranslator'; diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js new file mode 100644 index 000000000..0eb3a5399 --- /dev/null +++ b/src/components/RegistryLoader.js @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; + +import { ModulesContext } from '../ModulesContext'; + +// TODO: should this be handled by registry? +const parseModules = (remotes) => { + const modules = { app: [], plugin: [], settings: [], handler: [] }; + + remotes.forEach(remote => { + const { actsAs, ...rest } = remote; + actsAs.forEach(type => modules[type].push(rest)); + }); + + return modules; +}; + +// TODO: pass it via stripes config +const registryUrl = 'http://localhost:3001/registry'; + +const RegistryLoader = ({ children }) => { + const { formatMessage } = useIntl(); + const [modules, setModules] = useState(); + + useEffect(() => { + const translateModule = (module) => ({ + ...module, + displayName: module.displayName ? formatMessage({ id: module.displayName }) : undefined, + }); + + const translateModules = ({ app, plugin, settings, handler }) => ({ + app: app.map(translateModule), + plugin: plugin.map(translateModule), + settings: settings.map(translateModule), + handler: handler.map(translateModule), + }); + + const fetchRegistry = async () => { + const response = await fetch(registryUrl); + const registry = await response.json(); + const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); + const parsedModules = translateModules(parseModules(remotes)); + + setModules(parsedModules); + }; + + fetchRegistry(); + // We know what we are doing here so just ignore the dependency warning about 'formatMessage' + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {modules ? children : null} + + ); +}; + +RegistryLoader.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + PropTypes.func, + ]) +}; + + +export default RegistryLoader; diff --git a/src/components/index.js b/src/components/index.js index 015f3867a..a8ba4dee3 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -10,10 +10,8 @@ export { default as MainContainer } from './MainContainer'; export { default as MainNav } from './MainNav'; export { default as ModuleContainer } from './ModuleContainer'; export { withModule, withModules } from './Modules'; -export { default as ModuleTranslator } from './ModuleTranslator'; export { default as OrganizationLogo } from './OrganizationLogo'; export { default as OverlayContainer } from './OverlayContainer'; -export { default as Root } from './Root'; export { default as SSOLogin } from './SSOLogin'; export { default as SystemSkeleton } from './SystemSkeleton'; export { default as TitledRoute } from './TitledRoute'; diff --git a/src/loadRemoteComponent.js b/src/loadRemoteComponent.js new file mode 100644 index 000000000..687e6ee7a --- /dev/null +++ b/src/loadRemoteComponent.js @@ -0,0 +1,21 @@ +// https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers +export default async function loadRemoteComponent(remoteUrl, remoteName) { + const container = await fetch(remoteUrl) + .then((res) => res.text()) + .then((source) => { + const script = document.createElement('script'); + script.textContent = source; + document.body.appendChild(script); + return window[remoteName]; + }); + + // eslint-disable-next-line no-undef + await __webpack_init_sharing__('default'); + + // eslint-disable-next-line no-undef + await container.init(__webpack_share_scopes__.default); + + const factory = await container.get('./MainEntry'); + const Module = await factory(); + return Module; +} diff --git a/src/locationService.js b/src/locationService.js index 7ad0fb755..d3dcab3bd 100644 --- a/src/locationService.js +++ b/src/locationService.js @@ -35,7 +35,7 @@ export function isQueryResourceModule(module, location) { } export function getCurrentModule(modules, location) { - const { app, settings } = modules; + const { app, settings } = modules ?? { app: [], settings: [] }; return app.concat(settings).find(m => isQueryResourceModule(m, location)); } diff --git a/src/translateModules.js b/src/translateModules.js deleted file mode 100644 index e69de29bb..000000000 From eac40b82089d321135699c43d8181aea2a4ca88f Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 25 Apr 2023 21:08:19 -0400 Subject: [PATCH 02/28] Cleanup --- src/AppRoutes.js | 1 + src/Pluggable.js | 4 +++- src/RootWithIntl.js | 1 - src/components/Root/Root.js | 8 -------- src/components/Settings/Settings.js | 6 +++--- src/gatherActions.js | 12 ------------ 6 files changed, 7 insertions(+), 25 deletions(-) diff --git a/src/AppRoutes.js b/src/AppRoutes.js index e284af4d3..4996ba727 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -27,6 +27,7 @@ const AppRoutes = ({ modules, stripes }) => { const connect = connectFor(module.module, stripes.epics, stripes.logger); let ModuleComponent; + try { ModuleComponent = connect(RemoteComponent); } catch (error) { diff --git a/src/Pluggable.js b/src/Pluggable.js index 75c07cd31..f411434d1 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; +import loadRemoteComponent from './loadRemoteComponent'; const Pluggable = (props) => { const modules = useModules(); @@ -23,7 +24,8 @@ const Pluggable = (props) => { } if (best) { - const Child = props.stripes.connect(best.getModule()); + const RemoteComponent = React.lazy(() => loadRemoteComponent(best.url, best.name)); + const Child = props.stripes.connect(RemoteComponent); cached.push({ Child, diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 143387034..c821a2621 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -17,7 +17,6 @@ import { MainContainer, MainNav, ModuleContainer, - ModuleTranslator, TitledRoute, Front, OIDCRedirect, diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index caf9680dd..9e4252bde 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -9,7 +9,6 @@ import { QueryClientProvider } from 'react-query'; import { ApolloProvider } from '@apollo/client'; import { ErrorBoundary } from '@folio/stripes-components'; -import { metadata, icons } from 'stripes-config'; import { ConnectContext } from '@folio/stripes-connect'; import initialReducers from '../../initialReducers'; @@ -29,11 +28,6 @@ import './Root.css'; import { withModules } from '../Modules'; import { FFetch } from './FFetch'; -if (!metadata) { - // eslint-disable-next-line no-console - console.error('No metadata harvested from package files, so you will not get app icons. Probably the stripes-core in your Stripes CLI is too old. Try `yarn global upgrade @folio/stripes-cli`'); -} - class Root extends Component { constructor(...args) { super(...args); @@ -150,8 +144,6 @@ class Root extends Component { locale, timezone, currency, - metadata, - icons, setLocale: (localeValue) => { loadTranslations(store, localeValue, defaultTranslations); }, setTimezone: (timezoneValue) => { store.dispatch(setTimezone(timezoneValue)); }, setCurrency: (currencyValue) => { store.dispatch(setCurrency(currencyValue)); }, diff --git a/src/components/Settings/Settings.js b/src/components/Settings/Settings.js index 3d687ae30..b56763f82 100644 --- a/src/components/Settings/Settings.js +++ b/src/components/Settings/Settings.js @@ -32,6 +32,7 @@ import AppIcon from '../AppIcon'; import { packageName } from '../../constants'; import RouteErrorBoundary from '../RouteErrorBoundary'; import { ModuleHierarchyProvider } from '../ModuleHierarchy'; +import loadRemoteComponent from '../../loadRemoteComponent'; import css from './Settings.css'; @@ -60,11 +61,10 @@ const Settings = ({ stripes }) => { .map((m) => { try { const connect = connectFor(m.module, stripes.epics, stripes.logger); - const module = m.getModule(); - + const RemoteComponent = React.lazy(() => loadRemoteComponent(m.url, m.name)); return { module: m, - Component: connect(module), + Component: connect(RemoteComponent), moduleStripes: stripes.clone({ connect }), }; } catch (error) { diff --git a/src/gatherActions.js b/src/gatherActions.js index abdc874e7..47c825fe9 100644 --- a/src/gatherActions.js +++ b/src/gatherActions.js @@ -1,6 +1,3 @@ -// Gather actionNames from all registered modules for hot-key mapping - -import { modules } from 'stripes-config'; import stripesComponents from '@folio/stripes-components/package'; function addKeys(moduleName, register, list) { @@ -15,15 +12,6 @@ function addKeys(moduleName, register, list) { export default function gatherActions() { const allActions = {}; - - for (const key of Object.keys(modules)) { - const set = modules[key]; - for (const key2 of Object.keys(set)) { - const module = set[key2]; - addKeys(module.module, allActions, module.actionNames); - } - } - addKeys('stripes-components', allActions, (stripesComponents.stripes || {}).actionNames); return Object.keys(allActions); From b6aa8f94a276376c38932edac520311ee4c16a42 Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 25 Apr 2023 21:40:31 -0400 Subject: [PATCH 03/28] Cache remote components --- src/loadRemoteComponent.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/loadRemoteComponent.js b/src/loadRemoteComponent.js index 687e6ee7a..3d13a064f 100644 --- a/src/loadRemoteComponent.js +++ b/src/loadRemoteComponent.js @@ -1,13 +1,14 @@ // https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers export default async function loadRemoteComponent(remoteUrl, remoteName) { - const container = await fetch(remoteUrl) - .then((res) => res.text()) - .then((source) => { - const script = document.createElement('script'); - script.textContent = source; - document.body.appendChild(script); - return window[remoteName]; - }); + if (!window[remoteName]) { + const response = await fetch(remoteUrl); + const source = await response.text(); + const script = document.createElement('script'); + script.textContent = source; + document.body.appendChild(script); + } + + const container = window[remoteName]; // eslint-disable-next-line no-undef await __webpack_init_sharing__('default'); @@ -17,5 +18,6 @@ export default async function loadRemoteComponent(remoteUrl, remoteName) { const factory = await container.get('./MainEntry'); const Module = await factory(); + return Module; } From ff5e9a3ebe0ec78a6bc7ad5b396d7a2e835bbed2 Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Tue, 25 Apr 2023 22:16:41 -0400 Subject: [PATCH 04/28] Prefetch handlers --- src/components/RegistryLoader.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index 0eb3a5399..c17bf73a8 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; import { ModulesContext } from '../ModulesContext'; +import loadRemoteComponent from '../loadRemoteComponent'; // TODO: should this be handled by registry? const parseModules = (remotes) => { @@ -41,6 +42,15 @@ const RegistryLoader = ({ children }) => { const registry = await response.json(); const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); const parsedModules = translateModules(parseModules(remotes)); + const { handler: handlerModules } = parsedModules; + + // prefetch all handlers so they can be executed in a sync way. + if (handlerModules) { + await Promise.all(handlerModules.map(async (module) => { + const component = await loadRemoteComponent(module.url, module.name); + module.getModule = () => component?.default; + })); + } setModules(parsedModules); }; From 5ab48375c41ac89ec83c8871557e1e0350a1bd41 Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Thu, 27 Apr 2023 14:10:05 -0400 Subject: [PATCH 05/28] Align stripes-shared-context correctly --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 05dc18b3a..579f7cfbf 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ }, "dependencies": { "@apollo/client": "^3.2.1", + "@folio/stripes-shared-context": "1.0.0", "classnames": "^2.2.5", "core-js": "^3.26.1", "final-form": "^4.18.2", From 9fd0420860be7749883ac8f5b967cc6d7e54549b Mon Sep 17 00:00:00 2001 From: Michal Kuklis Date: Thu, 27 Apr 2023 14:17:36 -0400 Subject: [PATCH 06/28] Update @folio/stripes-shared-context version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 579f7cfbf..479e15f84 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ }, "dependencies": { "@apollo/client": "^3.2.1", - "@folio/stripes-shared-context": "1.0.0", + "@folio/stripes-shared-context": "^1.0.0", "classnames": "^2.2.5", "core-js": "^3.26.1", "final-form": "^4.18.2", From 3b4fca0e9f4b65cb7e61a5cb82483497cafdd792 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 25 May 2023 09:57:29 -0500 Subject: [PATCH 07/28] wrap Pluggable's rendered Child in suspense to isolate react-dom's hiding of ui-elements --- src/Pluggable.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Pluggable.js b/src/Pluggable.js index f411434d1..73d976a2b 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -1,5 +1,6 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, Suspense } from 'react'; import PropTypes from 'prop-types'; +import { Icon } from '@folio/stripes-components'; import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; @@ -35,12 +36,14 @@ const Pluggable = (props) => { } return cached; - }, [plugins]); + }, [plugins, props.type]); if (cachedPlugins.length) { return cachedPlugins.map(({ plugin, Child }) => ( - + }> + + )); } From 86b32735cf9df8e1d15b284126aff70d6c57f4dc Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Thu, 8 Jun 2023 17:13:00 -0400 Subject: [PATCH 08/28] STCOR-718 load remote translations (#1309) Draft: load translations when loading remote modules Note: QueryClientProvider must be explicitly shared See https://tanstack.com/query/v3/docs/react/reference/QueryClientProvider Refs STCOR-718, STRIPES-861 --- src/RootWithIntl.js | 246 +++++++++++++++---------------- src/components/RegistryLoader.js | 92 ++++++++++-- src/components/Root/Root.js | 2 +- src/okapiReducer.js | 2 +- 4 files changed, 205 insertions(+), 137 deletions(-) diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index c821a2621..baa2a1e26 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -53,130 +53,130 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut return ( - + - - - - - { isAuthenticated || token || disableAuth ? - <> - - - - {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } - - { (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( - - - {connectedStripes.config.useSecureTokens && } - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - - - )} - - - - : - - {/* The ? after :token makes that part of the path optional, so that token may optionally - be passed in via URL parameter to avoid length restrictions */} - } - /> - } - key="sso-landing" - /> - } - key="oidc-landing" - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - } - - - - + + + + + { isAuthenticated || token || disableAuth ? + <> + + + + {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } + + { (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( + + + {connectedStripes.config.useSecureTokens && } + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + )} + + + + : + + {/* The ? after :token makes that part of the path optional, so that token may optionally + be passed in via URL parameter to avoid length restrictions */} + } + /> + } + key="sso-landing" + /> + } + key="oidc-landing" + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } + + + + diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index c17bf73a8..41501e2ed 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; +import { setTranslations } from '../okapiActions'; import { ModulesContext } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; @@ -20,28 +21,94 @@ const parseModules = (remotes) => { // TODO: pass it via stripes config const registryUrl = 'http://localhost:3001/registry'; -const RegistryLoader = ({ children }) => { +const appTranslations = []; + +/** + * loadTranslations + * return a promise that fetches translations for the given module and then + * dispatches the translations. + * @param {object} stripes + * @param {object} module info read from the registry + * + * @returns {Promise} + */ +const loadTranslations = (stripes, module) => { + const url = `${module.host}:${module.port}`; + + const parentLocale = stripes.locale.split('-')[0]; + // Since moment.js don't support translations like it or it-IT-u-nu-latn + // we need to build string like it_IT for fetch call + const loadedLocale = stripes.locale.replace('-', '_').split('-')[0]; + + // react-intl provides things like pt-BR. + // lokalise provides things like pt_BR. + // so we have to translate '-' to '_' because the translation libraries + // don't know how to talk to each other. sheesh. + const region = stripes.locale.replace('-', '_'); + + // Here we put additional condition because languages + // like Japan we need to use like ja, but with numeric system + // Japan language builds like ja_u, that incorrect. We need to be safe from that bug. + if (!appTranslations.includes(url)) { + appTranslations.push(url); + return fetch(`${url}/translations/${region}.json`) + .then((response) => { + if (response.ok) { + return response.json().then((translations) => { + // translation entries look like "key: val" + // but we want "ui-${app}.key: val" + const prefix = module.name.replace('folio_', 'ui-'); + const keyed = []; + Object.keys(translations).forEach(key => { + keyed[`${prefix}.${key}`] = translations[key]; + }); + + // I thought dispatch was synchronous, but without a return + // statement here the calling function's invocations of + // formatMessage() don't see the updated values in the store + return stripes.store.dispatch(setTranslations({ ...stripes.okapi.translations, ...keyed })); + }); + } else { + throw new Error(`Could not load translations for ${module}`); + } + }); + } else { + return Promise.resolve(); + } +}; + + +const RegistryLoader = ({ stripes, children }) => { const { formatMessage } = useIntl(); const [modules, setModules] = useState(); useEffect(() => { - const translateModule = (module) => ({ - ...module, - displayName: module.displayName ? formatMessage({ id: module.displayName }) : undefined, - }); + const translateModule = (module) => { + return loadTranslations(stripes, module) + .then(() => { + return { + ...module, + displayName: module.displayName ? formatMessage({ id: module.displayName }) : undefined, + }; + }) + .catch(e => { + // eslint-disable-next-line no-console + console.error(e); + }); + }; - const translateModules = ({ app, plugin, settings, handler }) => ({ - app: app.map(translateModule), - plugin: plugin.map(translateModule), - settings: settings.map(translateModule), - handler: handler.map(translateModule), + const translateModules = async ({ app, plugin, settings, handler }) => ({ + app: await Promise.all(app.map(translateModule)), + plugin: await Promise.all(plugin.map(translateModule)), + settings: await Promise.all(settings.map(translateModule)), + handler: await Promise.all(handler.map(translateModule)), }); const fetchRegistry = async () => { const response = await fetch(registryUrl); const registry = await response.json(); const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - const parsedModules = translateModules(parseModules(remotes)); + const parsedModules = await translateModules(parseModules(remotes)); const { handler: handlerModules } = parsedModules; // prefetch all handlers so they can be executed in a sync way. @@ -72,7 +139,8 @@ RegistryLoader.propTypes = { PropTypes.arrayOf(PropTypes.node), PropTypes.node, PropTypes.func, - ]) + ]), + stripes: PropTypes.object.isRequired, }; diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 9e4252bde..a0d93f361 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -164,7 +164,7 @@ class Root extends Component { - + Date: Fri, 9 Jun 2023 10:16:51 -0400 Subject: [PATCH 09/28] STCOR-725 load remote icons (#1317) Load remote icons, and clean up the translation loading a bit; it was still very much in draft form, and still is, but at least it doesn't throw lint errors everywhere now. Refs STCOR-725, STRIPES-861 --- src/components/RegistryLoader.js | 122 +++++++++++++++++-------------- src/components/Root/Root.js | 13 +++- src/okapiActions.js | 9 +++ src/okapiReducer.js | 3 + 4 files changed, 90 insertions(+), 57 deletions(-) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index 41501e2ed..f150142c9 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -1,8 +1,9 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; +import { okapi } from 'stripes-config'; -import { setTranslations } from '../okapiActions'; +import { addIcon, setTranslations } from '../okapiActions'; import { ModulesContext } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; @@ -19,9 +20,7 @@ const parseModules = (remotes) => { }; // TODO: pass it via stripes config -const registryUrl = 'http://localhost:3001/registry'; - -const appTranslations = []; +const registryUrl = okapi.registryUrl; /** * loadTranslations @@ -33,57 +32,75 @@ const appTranslations = []; * @returns {Promise} */ const loadTranslations = (stripes, module) => { - const url = `${module.host}:${module.port}`; - - const parentLocale = stripes.locale.split('-')[0]; - // Since moment.js don't support translations like it or it-IT-u-nu-latn - // we need to build string like it_IT for fetch call - const loadedLocale = stripes.locale.replace('-', '_').split('-')[0]; - - // react-intl provides things like pt-BR. - // lokalise provides things like pt_BR. - // so we have to translate '-' to '_' because the translation libraries - // don't know how to talk to each other. sheesh. - const region = stripes.locale.replace('-', '_'); - - // Here we put additional condition because languages - // like Japan we need to use like ja, but with numeric system - // Japan language builds like ja_u, that incorrect. We need to be safe from that bug. - if (!appTranslations.includes(url)) { - appTranslations.push(url); - return fetch(`${url}/translations/${region}.json`) - .then((response) => { - if (response.ok) { - return response.json().then((translations) => { - // translation entries look like "key: val" - // but we want "ui-${app}.key: val" - const prefix = module.name.replace('folio_', 'ui-'); - const keyed = []; - Object.keys(translations).forEach(key => { - keyed[`${prefix}.${key}`] = translations[key]; - }); - - // I thought dispatch was synchronous, but without a return - // statement here the calling function's invocations of - // formatMessage() don't see the updated values in the store - return stripes.store.dispatch(setTranslations({ ...stripes.okapi.translations, ...keyed })); + // construct a fully-qualified URL to load. + // + // locale strings include a name plus optional region and numbering system. + // we only care about the name and region. this stripes the numberin system + // and converts from kebab-case (the IETF standard) to snake_case (which we + // somehow adopted for our files in Lokalise). + const locale = stripes.locale.split('-u-nu-')[0].replace('-', '_'); + const url = `${module.host}:${module.port}/translations/${locale}.json`; + stripes.logger.log('core', `loading ${locale} translations for ${module.name}`); + + return fetch(url) + .then((response) => { + if (response.ok) { + return response.json().then((translations) => { + // 1. translation entries look like "key: val"; we want "ui-${app}.key: val" + // 2. module.name is snake_case (I have no idea why); we want kebab-case + const prefix = module.name.replace('folio_', 'ui-').replaceAll('_', '-'); + const keyed = []; + Object.keys(translations).forEach(key => { + keyed[`${prefix}.${key}`] = translations[key]; }); - } else { - throw new Error(`Could not load translations for ${module}`); + + const tx = { ...stripes.okapi.translations, ...keyed }; + + stripes.store.dispatch(setTranslations(tx)); + + // const tx = { ...stripes.okapi.translations, ...keyed }; + // console.log(`filters.status.active: ${tx['ui-users.filters.status.active']}`) + return stripes.setLocale(stripes.locale, tx); + }); + } else { + throw new Error(`Could not load translations for ${module}`); + } + }); +}; + +/** + * loadIcons + * Register remotely-hosted icons with stripes by dispatching addIcon + * for each element of the module's icons array. + * + * @param {object} stripes + * @param {object} module info read from the registry + * + * @returns {void} + */ +const loadIcons = (stripes, module) => { + if (module.icons && module.icons.length) { + stripes.logger.log('core', `loading icons for ${module.module}`); + module.icons.forEach(i => { + const icon = { + [i.name]: { + src: `${module.host}:${module.port}/icons/${i.name}.svg`, + alt: i.title, } - }); - } else { - return Promise.resolve(); + }; + stripes.store.dispatch(addIcon(module.module, icon)); + }); } }; - const RegistryLoader = ({ stripes, children }) => { const { formatMessage } = useIntl(); const [modules, setModules] = useState(); useEffect(() => { - const translateModule = (module) => { + const loadModuleAssets = (module) => { + loadIcons(stripes, module); + return loadTranslations(stripes, module) .then(() => { return { @@ -97,18 +114,17 @@ const RegistryLoader = ({ stripes, children }) => { }); }; - const translateModules = async ({ app, plugin, settings, handler }) => ({ - app: await Promise.all(app.map(translateModule)), - plugin: await Promise.all(plugin.map(translateModule)), - settings: await Promise.all(settings.map(translateModule)), - handler: await Promise.all(handler.map(translateModule)), + const loadModules = async ({ app, plugin, settings, handler }) => ({ + app: await Promise.all(app.map(loadModuleAssets)), + plugin: await Promise.all(plugin.map(loadModuleAssets)), + settings: await Promise.all(settings.map(loadModuleAssets)), + handler: await Promise.all(handler.map(loadModuleAssets)), }); const fetchRegistry = async () => { - const response = await fetch(registryUrl); - const registry = await response.json(); + const registry = await fetch(registryUrl).then((response) => response.json()); const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - const parsedModules = await translateModules(parseModules(remotes)); + const parsedModules = await loadModules(parseModules(remotes)); const { handler: handlerModules } = parsedModules; // prefetch all handlers so they can be executed in a sync way. diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index a0d93f361..2d032d9fc 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -15,7 +15,7 @@ import initialReducers from '../../initialReducers'; import enhanceReducer from '../../enhanceReducer'; import createApolloClient from '../../createApolloClient'; import createReactQueryClient from '../../createReactQueryClient'; -import { setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; +import { addIcon, setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; import { loadTranslations, checkOkapiSession } from '../../loginServices'; import { getQueryResourceKey, getCurrentModule } from '../../locationService'; import Stripes from '../../Stripes'; @@ -111,7 +111,7 @@ class Root extends Component { } render() { - const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; + const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, icons, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; if (serverDown) { // note: this isn't i18n'ed because we haven't rendered an IntlProvider yet. return
Error: server is forbidden, unreachable or down. Clear the cookies? Use incognito mode? VPN issue?
; @@ -144,7 +144,9 @@ class Root extends Component { locale, timezone, currency, - setLocale: (localeValue) => { loadTranslations(store, localeValue, defaultTranslations); }, + icons, + addIcon: (key, icon) => { store.dispatch(addIcon(key, icon)); }, + setLocale: (localeValue, tx) => { return loadTranslations(store, localeValue, { ...defaultTranslations, ...tx }); }, setTimezone: (timezoneValue) => { store.dispatch(setTimezone(timezoneValue)); }, setCurrency: (currencyValue) => { store.dispatch(setCurrency(currencyValue)); }, updateUser: (userValue) => { store.dispatch(updateCurrentUser(userValue)); }, @@ -164,7 +166,7 @@ class Root extends Component { - + Date: Fri, 4 Aug 2023 16:08:46 -0400 Subject: [PATCH 10/28] STCOR-718 correctly set apps' localized displayName Correctly set each apps' localized `displayName` attribute. It isn't totally clear to me why this doesn't work via `formattedMessage`. It seems that something is happening asynchronously that we don't realize is async, and therefore don't await, and then we end up calling `formatMessage()` before the translations have been pushed to the store. In any case, pulling the value straight from the translations array works fine. Refs STCOR-718 --- src/components/RegistryLoader.js | 125 +++++++++++++++++++++---------- src/loginServices.js | 8 +- 2 files changed, 90 insertions(+), 43 deletions(-) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index f150142c9..665b05d6e 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -1,13 +1,19 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; import { okapi } from 'stripes-config'; -import { addIcon, setTranslations } from '../okapiActions'; import { ModulesContext } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; -// TODO: should this be handled by registry? +/** + * parseModules + * Map the list of applications to a hash keyed by acts-as type (app, plugin, + * settings, handler) where the value of each is an array of corresponding + * applications. + * + * @param {array} remotes + * @returns {app: [], plugin: [], settings: [], handler: []} + */ const parseModules = (remotes) => { const modules = { app: [], plugin: [], settings: [], handler: [] }; @@ -19,13 +25,11 @@ const parseModules = (remotes) => { return modules; }; -// TODO: pass it via stripes config -const registryUrl = okapi.registryUrl; - /** * loadTranslations - * return a promise that fetches translations for the given module and then - * dispatches the translations. + * return a promise that fetches translations for the given module, + * dispatches setLocale, and then returns the translations. + * * @param {object} stripes * @param {object} module info read from the registry * @@ -56,11 +60,12 @@ const loadTranslations = (stripes, module) => { const tx = { ...stripes.okapi.translations, ...keyed }; - stripes.store.dispatch(setTranslations(tx)); + // stripes.store.dispatch(setTranslations(tx)); // const tx = { ...stripes.okapi.translations, ...keyed }; // console.log(`filters.status.active: ${tx['ui-users.filters.status.active']}`) - return stripes.setLocale(stripes.locale, tx); + stripes.setLocale(stripes.locale, tx); + return tx; }); } else { throw new Error(`Could not load translations for ${module}`); @@ -82,52 +87,92 @@ const loadIcons = (stripes, module) => { if (module.icons && module.icons.length) { stripes.logger.log('core', `loading icons for ${module.module}`); module.icons.forEach(i => { + stripes.logger.log('core', ` > ${i.name}`); + const icon = { [i.name]: { src: `${module.host}:${module.port}/icons/${i.name}.svg`, alt: i.title, } }; - stripes.store.dispatch(addIcon(module.module, icon)); + stripes.addIcon(module.module, icon); }); } }; +/** + * loadModuleAssets + * Load a module's icons, translations, and sounds. + * @param {object} stripes + * @param {object} module info read from the registry + * @returns {} copy of the module, plus the key `displayName` containing its localized name + */ +const loadModuleAssets = (stripes, module) => { + // register icons + loadIcons(stripes, module); + + // register sounds + // TODO loadSounds(stripes, module); + + // register translations + return loadTranslations(stripes, module) + .then((tx) => { + return { + ...module, + // tx[module.displayName] instead of formatMessage({ id: module.displayName}) + // because ... I'm not sure exactly. I suspect the answer is that we're doing + // something async somewhere but not realizing it, and therefore not returning + // a promise. thus, loadTranslations returns before it's actually done loading + // translations, and calling formatMessage(...) here executes before the new + // values are loaded. + // + // TODO: update when modules are served with compiled translations + displayName: module.displayName ? tx[module.displayName] : module.module, + }; + }) + .catch(e => { + // eslint-disable-next-line no-console + console.error(e); + }); +}; + +/** + * loadModules + * NB: this means multi-type modules, i.e. those like `actsAs: [app, settings]` + * will be loaded multiple times. I'm not sure that's right. + * @param {props} + * @returns Promise + */ +const loadModules = async ({ app, plugin, settings, handler, stripes }) => ({ + app: await Promise.all(app.map(i => loadModuleAssets(stripes, i))), + plugin: await Promise.all(plugin.map(i => loadModuleAssets(stripes, i))), + settings: await Promise.all(settings.map(i => loadModuleAssets(stripes, i))), + handler: await Promise.all(handler.map(i => loadModuleAssets(stripes, i))), +}); + + +/** + * Registry Loader + * @param {object} stripes + * @param {*} children + * @returns + */ const RegistryLoader = ({ stripes, children }) => { - const { formatMessage } = useIntl(); const [modules, setModules] = useState(); + // read the list of registered apps from the registry, useEffect(() => { - const loadModuleAssets = (module) => { - loadIcons(stripes, module); - - return loadTranslations(stripes, module) - .then(() => { - return { - ...module, - displayName: module.displayName ? formatMessage({ id: module.displayName }) : undefined, - }; - }) - .catch(e => { - // eslint-disable-next-line no-console - console.error(e); - }); - }; - - const loadModules = async ({ app, plugin, settings, handler }) => ({ - app: await Promise.all(app.map(loadModuleAssets)), - plugin: await Promise.all(plugin.map(loadModuleAssets)), - settings: await Promise.all(settings.map(loadModuleAssets)), - handler: await Promise.all(handler.map(loadModuleAssets)), - }); - const fetchRegistry = async () => { - const registry = await fetch(registryUrl).then((response) => response.json()); + // read the list of registered apps + const registry = await fetch(okapi.registryUrl).then((response) => response.json()); + + // remap registry from an object shaped like { key1: app1, key2: app2, ...} + // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - const parsedModules = await loadModules(parseModules(remotes)); - const { handler: handlerModules } = parsedModules; + const parsedModules = await loadModules({ stripes, ...parseModules(remotes) }); // prefetch all handlers so they can be executed in a sync way. + const { handler: handlerModules } = parsedModules; if (handlerModules) { await Promise.all(handlerModules.map(async (module) => { const component = await loadRemoteComponent(module.url, module.name); @@ -139,8 +184,8 @@ const RegistryLoader = ({ stripes, children }) => { }; fetchRegistry(); - // We know what we are doing here so just ignore the dependency warning about 'formatMessage' - // eslint-disable-next-line react-hooks/exhaustive-deps + // We know what we are doing here so just ignore the dependency warning about 'formatMessage' + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/src/loginServices.js b/src/loginServices.js index 5a38dc465..96dc17f4f 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -220,14 +220,16 @@ export function loadTranslations(store, locale, defaultTranslations = {}) { // Here we put additional condition because languages // like Japan we need to use like ja, but with numeric system // Japan language builds like ja_u, that incorrect. We need to be safe from that bug. - return fetch(translations[region] ? translations[region] : - translations[loadedLocale] || translations[[parentLocale]]) + const translationsUrl = translations[region] ?? (translations[loadedLocale] || translations[parentLocale]); + return fetch(translationsUrl) .then((response) => { if (response.ok) { - response.json().then((stripesTranslations) => { + return response.json().then((stripesTranslations) => { store.dispatch(setTranslations(Object.assign(stripesTranslations, defaultTranslations))); store.dispatch(setLocale(locale)); }); + } else { + return Promise.reject(new Error(`Could not load translations from ${translationsUrl}`)); } }); } From f66ad620f7d5e62a5b6cf09d3efea32a7c3a2244 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Fri, 4 Aug 2023 16:11:19 -0400 Subject: [PATCH 11/28] STCOR-725 correctly load multiple icons per app Correctly handle multiple icons per application. Refs STCOR-725 --- src/okapiReducer.js | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/okapiReducer.js b/src/okapiReducer.js index 05f17ea4e..ecf6a30fb 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -134,8 +134,34 @@ export default function okapiReducer(state = {}, action) { case OKAPI_REDUCER_ACTIONS.TOGGLE_RTR_MODAL: { return { ...state, rtrModalIsVisible: action.isVisible }; } - case OKAPI_REDUCER_ACTIONS.ADD_ICON: - return { ...state, icons: { ...state.icons, [action.key]: action.icon } }; + + /** + * state.icons looks like + * { + * "@folio/some-app": { + * app: { alt, src}, + * otherIcon: { alt, src } + * }, + * "@folio/other-app": { app: ...} + * } + * + * action.key looks like @folio/some-app or @folio/other-app + * action.icon looks like { alt: ... } or { otherIcon: ... } + */ + case OKAPI_REDUCER_ACTIONS.ADD_ICON: { + let val = action.icon; + + // if there are already icons defined for this key, + // add this payload to them + if (state.icons?.[action.key]) { + val = { + ...state.icons[action.key], + ...action.icon, + }; + } + + return { ...state, icons: { ...state.icons, [action.key]: val } }; + } default: return state; From 44bd6ddc9cd7004fa6e91a06924029ff1087bb78 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Wed, 4 Dec 2024 14:56:30 -0500 Subject: [PATCH 12/28] refactor catch up Major refactoring in stripes-core between this branch's initial work and the present lead to some discrepancies. The only change of note here, I think, is the relocation of `` from ModuleRoutes down into AppRoutes. It isn't clear to me why that was necessary or why it worked. It was just a hunch that I tried ... and it worked. Prior to that change, AppRoutes would get stuck in a render loop, infinitely reloading (yes, even the memoized functions). I don't have a good explanation for the bug or the fix. --- bootstrap.js | 9 +- index.js | 1 - src/AppRoutes.js | 63 +++---- src/ModuleRoutes.js | 6 +- src/Pluggable.js | 2 + src/RootWithIntl.js | 248 +++++++++++++------------- src/components/About/WarningBanner.js | 4 +- src/loginServices.js | 2 +- 8 files changed, 165 insertions(+), 170 deletions(-) diff --git a/bootstrap.js b/bootstrap.js index 2227a0edc..bdb96b828 100644 --- a/bootstrap.js +++ b/bootstrap.js @@ -1,13 +1,10 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; - import React from 'react'; import { createRoot } from 'react-dom/client'; import App from './src/App'; -export default function init() { - const container = document.getElementById('root'); - const root = createRoot(container); - root.render(); -} +const container = document.getElementById('root'); +const root = createRoot(container); +root.render(); diff --git a/index.js b/index.js index 3a9510d40..eb5c7e073 100644 --- a/index.js +++ b/index.js @@ -49,7 +49,6 @@ export { supportedNumberingSystems } from './src/loginServices'; export { userLocaleConfig } from './src/loginServices'; export * from './src/consortiaServices'; export { default as queryLimit } from './src/queryLimit'; -export { default as init } from './src/init'; /* localforage wrappers hide the session key */ export { getOkapiSession, getTokenExpiry, setTokenExpiry } from './src/loginServices'; diff --git a/src/AppRoutes.js b/src/AppRoutes.js index 4996ba727..f01cc26a4 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -1,8 +1,9 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, Suspense } from 'react'; import { Route } from 'react-router-dom'; import PropTypes from 'prop-types'; import { connectFor } from '@folio/stripes-connect'; +import { LoadingView } from '@folio/stripes-components'; import { StripesContext } from './StripesContext'; import TitleManager from './components/TitleManager'; @@ -50,37 +51,39 @@ const AppRoutes = ({ modules, stripes }) => { }, [modules.app, stripes]); return cachedModules.map(({ ModuleComponent, connect, module, name, moduleStripes, stripes: propsStripes, displayName }) => ( - { - const data = { displayName, name }; + }> + { + const data = { displayName, name }; - // allow SELECT_MODULE handlers to intervene - const handlerComponents = getEventHandlers(events.SELECT_MODULE, moduleStripes, modules.handler, data); - if (handlerComponents.length) { - return handlerComponents.map(Handler => ()); - } + // allow SELECT_MODULE handlers to intervene + const handlerComponents = getEventHandlers(events.SELECT_MODULE, moduleStripes, modules.handler, data); + if (handlerComponents.length) { + return handlerComponents.map(Handler => ()); + } - return ( - - -
- - - - - -
-
-
- ); - }} - /> + return ( + + +
+ + + + + +
+
+
+ ); + }} + /> +
)); }; diff --git a/src/ModuleRoutes.js b/src/ModuleRoutes.js index d1dc65ce0..08046067f 100644 --- a/src/ModuleRoutes.js +++ b/src/ModuleRoutes.js @@ -71,11 +71,7 @@ function ModuleRoutes({ stripes }) { ); } - return ( - }> - - - ); + return ; }} ); diff --git a/src/Pluggable.js b/src/Pluggable.js index 73d976a2b..6685366e1 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -1,6 +1,8 @@ import React, { useMemo, Suspense } from 'react'; import PropTypes from 'prop-types'; + import { Icon } from '@folio/stripes-components'; + import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index baa2a1e26..118da5bd4 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -53,131 +53,129 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut return ( - - - - - - - { isAuthenticated || token || disableAuth ? - <> - - - - {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } - - { (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( - - - {connectedStripes.config.useSecureTokens && } - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - - - )} - - - - : - - {/* The ? after :token makes that part of the path optional, so that token may optionally - be passed in via URL parameter to avoid length restrictions */} - } - /> - } - key="sso-landing" - /> - } - key="oidc-landing" - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - } - - - - - + + + + + + { isAuthenticated || token || disableAuth ? + <> + + + + {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } + + { (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( + + + {connectedStripes.config.useSecureTokens && } + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + )} + + + + : + + {/* The ? after :token makes that part of the path optional, so that token may optionally + be passed in via URL parameter to avoid length restrictions */} + } + /> + } + key="sso-landing" + /> + } + key="oidc-landing" + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } + + + + diff --git a/src/components/About/WarningBanner.js b/src/components/About/WarningBanner.js index 1ff3867b2..528f806cc 100644 --- a/src/components/About/WarningBanner.js +++ b/src/components/About/WarningBanner.js @@ -48,7 +48,7 @@ const WarningBanner = ({ {missingModulesMsg} @@ -61,7 +61,7 @@ const WarningBanner = ({ {incompatibleModuleMsg} diff --git a/src/loginServices.js b/src/loginServices.js index 96dc17f4f..17023704a 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -252,7 +252,7 @@ function dispatchLocale(url, store, tenant) { }) .then((response) => { if (response.ok) { - response.json().then((json) => { + return response.json().then((json) => { if (json.configs?.length) { const localeValues = JSON.parse(json.configs[0].value); const { locale, timezone, currency } = localeValues; From 72a3f544b2118a2a020382b030237942c4740286 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 14 Nov 2025 15:07:00 -0600 Subject: [PATCH 13/28] add registry to token util's set of paths for creds --- src/components/Root/token-util.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index 2ea9097e5..4aa5ed519 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -90,6 +90,7 @@ export const isAuthenticationRequest = (resource, oUrl) => { '/bl-users/login-with-expiry', '/bl-users/_self', '/users-keycloak/_self', + '/registry' ]; return !!permissible.find(i => string.startsWith(`${oUrl}${i}`)); From 3cf47fd49765825674bdaeabc10cc89d3b42f7ff Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 14 Nov 2025 15:09:16 -0600 Subject: [PATCH 14/28] ship context out to stripes-shared-context --- src/App.js | 2 +- src/ModulesContext.js | 7 +------ src/components/MainNav/AppOrderProvider.js | 16 +++++++++++----- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/App.js b/src/App.js index db89ff325..6d7e7429c 100644 --- a/src/App.js +++ b/src/App.js @@ -2,6 +2,7 @@ import React, { Component, StrictMode } from 'react'; import PropTypes from 'prop-types'; import { okapi as okapiConfig, config } from 'stripes-config'; import merge from 'lodash/merge'; +import { modulesInitialState } from '@folio/stripes-shared-context'; import AppConfigError from './components/AppConfigError'; import connectErrorEpic from './connectErrorEpic'; @@ -11,7 +12,6 @@ import configureStore from './configureStore'; import gatherActions from './gatherActions'; import { destroyStore } from './mainActions'; import { getModules } from './entitlementService'; -import { modulesInitialState } from './ModulesContext'; import css from './components/SessionEventContainer/style.css'; import Root from './components/Root'; diff --git a/src/ModulesContext.js b/src/ModulesContext.js index 863526877..0c5b57d15 100644 --- a/src/ModulesContext.js +++ b/src/ModulesContext.js @@ -1,6 +1 @@ -import { useContext } from 'react'; -import { ModulesContext } from '@folio/stripes-shared-context'; - -export { ModulesContext }; -export default ModulesContext; -export const useModules = () => useContext(ModulesContext); +export { ModulesContext, modulesInitialState, useModules } from '@folio/stripes-shared-context'; diff --git a/src/components/MainNav/AppOrderProvider.js b/src/components/MainNav/AppOrderProvider.js index 4b790da10..8eeaa6bfe 100644 --- a/src/components/MainNav/AppOrderProvider.js +++ b/src/components/MainNav/AppOrderProvider.js @@ -2,10 +2,9 @@ import { createContext, useContext, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; +import isArray from 'lodash/isArray'; -import { useStripes } from '../../StripesContext'; -import { useModules } from '../../ModulesContext'; -import { LastVisitedContext } from '../LastVisited'; +import { LastVisitedContext, useModules, useStripes } from '@folio/stripes-shared-context'; import usePreferences from '../../hooks/usePreferences'; import { packageName } from '../../constants'; import settingsIcon from './settings.svg'; @@ -39,11 +38,11 @@ export const AppOrderContext = createContext({ * Function to update the preference. Accepts an list of objects with shape: * { name: string - the module package name, sans scope and `ui-` prefix } */ - updateList: () => {}, + updateList: () => { }, /** * Function to delete any the app order preference and reset the list. */ - reset: () => {}, + reset: () => { }, }); // hook for AppOrderContext consumption. @@ -103,6 +102,13 @@ function getAllowedApps(appModules, stripes, pathname, lastVisited, formatMessag route: SETTINGS_ROUTE }); } + + // use translated displayName rather that ast object; + apps.forEach((app) => { + if (isArray(app.displayName)) { + app.displayName = app.displayName[0].value; + } + }); return apps.toSorted((a, b) => a.displayName.localeCompare(b.displayName)); } From 21dd6adbc6aad646851d06c76c6bc6c3e1a8eff1 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 14 Nov 2025 15:09:46 -0600 Subject: [PATCH 15/28] preload all the things --- src/components/RegistryLoader.js | 120 ++++++++++++++++++++++++------- 1 file changed, 93 insertions(+), 27 deletions(-) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index 665b05d6e..c99905e46 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -14,13 +14,50 @@ import loadRemoteComponent from '../loadRemoteComponent'; * @param {array} remotes * @returns {app: [], plugin: [], settings: [], handler: []} */ -const parseModules = (remotes) => { +const parseModules = async (remotes) => { const modules = { app: [], plugin: [], settings: [], handler: [] }; - remotes.forEach(remote => { - const { actsAs, ...rest } = remote; - actsAs.forEach(type => modules[type].push(rest)); - }); + // TODO finish prefetching modules here.... + try { + const loaderArray = []; + remotes.forEach(async remote => { + const { name, url } = remote; + // setting getModule for backwards compatibility with parts of stripes that call it.. + loaderArray.push(loadRemoteComponent(url, name)); + }); + await Promise.all(loaderArray); + remotes.forEach((remote, i) => { + const { actsAs, name, url, ...rest } = remote; + const getModule = () => loaderArray[i].default; + actsAs.forEach(type => modules[type].push({ actsAs, name, url, getModule, ...rest })); + }); + } catch (e) { + console.error('Error parsing modules from registry', e); + } + + return modules; +}; + +const preloadModules = async (remotes) => { + const modules = { app: [], plugin: [], settings: [], handler: [] }; + + // TODO finish prefetching modules here.... + try { + const loaderArray = []; + remotes.forEach(async remote => { + const { name, url } = remote; + // setting getModule for backwards compatibility with parts of stripes that call it.. + loaderArray.push(loadRemoteComponent(url, name)); + }); + await Promise.all(loaderArray); + remotes.forEach((remote, i) => { + const { actsAs, name, url, ...rest } = remote; + const getModule = () => loaderArray[i].default; + actsAs.forEach(type => modules[type].push({ actsAs, name, url, getModule, ...rest })); + }); + } catch (e) { + console.error('Error parsing modules from registry', e); + } return modules; }; @@ -52,13 +89,13 @@ const loadTranslations = (stripes, module) => { return response.json().then((translations) => { // 1. translation entries look like "key: val"; we want "ui-${app}.key: val" // 2. module.name is snake_case (I have no idea why); we want kebab-case - const prefix = module.name.replace('folio_', 'ui-').replaceAll('_', '-'); - const keyed = []; - Object.keys(translations).forEach(key => { - keyed[`${prefix}.${key}`] = translations[key]; - }); + // const prefix = module.name.replace('folio_', 'ui-').replaceAll('_', '-'); + // const keyed = []; + // Object.keys(translations).forEach(key => { + // keyed[`${prefix}.${key}`] = translations[key]; + // }); - const tx = { ...stripes.okapi.translations, ...keyed }; + const tx = { ...stripes.okapi.translations, ...translations }; // stripes.store.dispatch(setTranslations(tx)); @@ -117,17 +154,29 @@ const loadModuleAssets = (stripes, module) => { // register translations return loadTranslations(stripes, module) .then((tx) => { + // tx[module.displayName] instead of formatMessage({ id: module.displayName}) + // because ... I'm not sure exactly. I suspect the answer is that we're doing + // something async somewhere but not realizing it, and therefore not returning + // a promise. thus, loadTranslations returns before it's actually done loading + // translations, and calling formatMessage(...) here executes before the new + // values are loaded. + // + // when translations are compiled, the value of the tx[module.displayName] is an array + // containing a single object with shape { type: 'messageFormatPattern', value: 'the actual string' } + // so we have to extract the value from that structure. + let newDisplayName; + if (module.displayName) { + if (typeof tx[module.displayName] === 'string') { + newDisplayName = tx[module.displayName]; + } else { + newDisplayName = tx[module.displayName][0].value; + } + } + return { ...module, - // tx[module.displayName] instead of formatMessage({ id: module.displayName}) - // because ... I'm not sure exactly. I suspect the answer is that we're doing - // something async somewhere but not realizing it, and therefore not returning - // a promise. thus, loadTranslations returns before it's actually done loading - // translations, and calling formatMessage(...) here executes before the new - // values are loaded. - // - // TODO: update when modules are served with compiled translations - displayName: module.displayName ? tx[module.displayName] : module.module, + displayName: module.displayName ? + newDisplayName : module.module, }; }) .catch(e => { @@ -150,6 +199,9 @@ const loadModules = async ({ app, plugin, settings, handler, stripes }) => ({ handler: await Promise.all(handler.map(i => loadModuleAssets(stripes, i))), }); +const loadAllModuleAssets = async (stripes, remotes) => { + return Promise.all(remotes.map((r) => loadModuleAssets(stripes, r))); +}; /** * Registry Loader @@ -169,17 +221,31 @@ const RegistryLoader = ({ stripes, children }) => { // remap registry from an object shaped like { key1: app1, key2: app2, ...} // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - const parsedModules = await loadModules({ stripes, ...parseModules(remotes) }); + // load module assets, then load modules... + const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); + // const parsedModules = await loadModules({ stripes, ...parseModules(remotes) }); + const parsedModules = await preloadModules(remotesWithLoadedAssets); // prefetch all handlers so they can be executed in a sync way. - const { handler: handlerModules } = parsedModules; - if (handlerModules) { - await Promise.all(handlerModules.map(async (module) => { - const component = await loadRemoteComponent(module.url, module.name); - module.getModule = () => component?.default; - })); + // const { handler: handlerModules } = parsedModules; + // if (handlerModules) { + // await Promise.all(handlerModules.map(async (module) => { + // const component = await loadRemoteComponent(module.url, module.name); + // module.getModule = () => component?.default; + // })); + // } + + // preload all modules... + for (const type in parsedModules) { + if (parsedModules[type]) { + parsedModules[type].forEach(async (module) => { + const loadedModule = await loadRemoteComponent(module.url, module.name); + module.getModule = () => loadedModule?.default; + }); + } } + // prefetch setModules(parsedModules); }; From 3deb33c8916ed52b3e3d7a8af15e4c65590f44cf Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 14 Nov 2025 15:10:53 -0600 Subject: [PATCH 16/28] go back to memo'd version --- src/Pluggable.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Pluggable.js b/src/Pluggable.js index e0f61e8d2..91bce4e53 100644 --- a/src/Pluggable.js +++ b/src/Pluggable.js @@ -6,7 +6,6 @@ import { Loading } from '@folio/stripes-components'; import { useModules } from './ModulesContext'; import { withStripes } from './StripesContext'; import { ModuleHierarchyProvider } from './components'; -import loadRemoteComponent from './loadRemoteComponent'; const Pluggable = (props) => { const modules = useModules(); @@ -27,8 +26,7 @@ const Pluggable = (props) => { } if (best) { - const RemoteComponent = React.lazy(() => loadRemoteComponent(best.url, best.name)); - const Child = props.stripes.connect(RemoteComponent); + const Child = props.stripes.connect(best.getModule()); cached.push({ Child, From c7563335bdb6783bc8de662efaee721155e79d16 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 14 Nov 2025 15:53:18 -0600 Subject: [PATCH 17/28] clean up --- src/components/RegistryLoader.js | 88 +++++++------------------------- 1 file changed, 18 insertions(+), 70 deletions(-) diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index c99905e46..c0426e5bd 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { okapi } from 'stripes-config'; @@ -6,7 +6,8 @@ import { ModulesContext } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; /** - * parseModules + * preloadModules + * Loads each module code and sets up its getModule function. * Map the list of applications to a hash keyed by acts-as type (app, plugin, * settings, handler) where the value of each is an array of corresponding * applications. @@ -14,46 +15,23 @@ import loadRemoteComponent from '../loadRemoteComponent'; * @param {array} remotes * @returns {app: [], plugin: [], settings: [], handler: []} */ -const parseModules = async (remotes) => { - const modules = { app: [], plugin: [], settings: [], handler: [] }; - - // TODO finish prefetching modules here.... - try { - const loaderArray = []; - remotes.forEach(async remote => { - const { name, url } = remote; - // setting getModule for backwards compatibility with parts of stripes that call it.. - loaderArray.push(loadRemoteComponent(url, name)); - }); - await Promise.all(loaderArray); - remotes.forEach((remote, i) => { - const { actsAs, name, url, ...rest } = remote; - const getModule = () => loaderArray[i].default; - actsAs.forEach(type => modules[type].push({ actsAs, name, url, getModule, ...rest })); - }); - } catch (e) { - console.error('Error parsing modules from registry', e); - } - - return modules; -}; const preloadModules = async (remotes) => { const modules = { app: [], plugin: [], settings: [], handler: [] }; - // TODO finish prefetching modules here.... try { const loaderArray = []; remotes.forEach(async remote => { const { name, url } = remote; - // setting getModule for backwards compatibility with parts of stripes that call it.. - loaderArray.push(loadRemoteComponent(url, name)); + loaderArray.push(loadRemoteComponent(url, name) + .then((module) => { + remote.getModule = () => module.default; + })); }); await Promise.all(loaderArray); - remotes.forEach((remote, i) => { - const { actsAs, name, url, ...rest } = remote; - const getModule = () => loaderArray[i].default; - actsAs.forEach(type => modules[type].push({ actsAs, name, url, getModule, ...rest })); + remotes.forEach((remote) => { + const { actsAs } = remote; + actsAs.forEach(type => modules[type].push({ ...remote })); }); } catch (e) { console.error('Error parsing modules from registry', e); @@ -155,11 +133,7 @@ const loadModuleAssets = (stripes, module) => { return loadTranslations(stripes, module) .then((tx) => { // tx[module.displayName] instead of formatMessage({ id: module.displayName}) - // because ... I'm not sure exactly. I suspect the answer is that we're doing - // something async somewhere but not realizing it, and therefore not returning - // a promise. thus, loadTranslations returns before it's actually done loading - // translations, and calling formatMessage(...) here executes before the new - // values are loaded. + // because updating store is async and we don't have the updated values quite yet... // // when translations are compiled, the value of the tx[module.displayName] is an array // containing a single object with shape { type: 'messageFormatPattern', value: 'the actual string' } @@ -186,19 +160,11 @@ const loadModuleAssets = (stripes, module) => { }; /** - * loadModules - * NB: this means multi-type modules, i.e. those like `actsAs: [app, settings]` - * will be loaded multiple times. I'm not sure that's right. + * loadAllModuleAssets + * Loads icons, translations, and sounds for all modules. Inserts the correct 'displayName' for each module. * @param {props} * @returns Promise */ -const loadModules = async ({ app, plugin, settings, handler, stripes }) => ({ - app: await Promise.all(app.map(i => loadModuleAssets(stripes, i))), - plugin: await Promise.all(plugin.map(i => loadModuleAssets(stripes, i))), - settings: await Promise.all(settings.map(i => loadModuleAssets(stripes, i))), - handler: await Promise.all(handler.map(i => loadModuleAssets(stripes, i))), -}); - const loadAllModuleAssets = async (stripes, remotes) => { return Promise.all(remotes.map((r) => loadModuleAssets(stripes, r))); }; @@ -222,35 +188,17 @@ const RegistryLoader = ({ stripes, children }) => { // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - // load module assets, then load modules... + // load module assets (translations, icons), then load modules... const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); - // const parsedModules = await loadModules({ stripes, ...parseModules(remotes) }); - const parsedModules = await preloadModules(remotesWithLoadedAssets); - // prefetch all handlers so they can be executed in a sync way. - // const { handler: handlerModules } = parsedModules; - // if (handlerModules) { - // await Promise.all(handlerModules.map(async (module) => { - // const component = await loadRemoteComponent(module.url, module.name); - // module.getModule = () => component?.default; - // })); - // } - - // preload all modules... - for (const type in parsedModules) { - if (parsedModules[type]) { - parsedModules[type].forEach(async (module) => { - const loadedModule = await loadRemoteComponent(module.url, module.name); - module.getModule = () => loadedModule?.default; - }); - } - } + // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. + const cachedModules = await preloadModules(remotesWithLoadedAssets); // prefetch - setModules(parsedModules); + setModules(cachedModules); }; fetchRegistry(); - // We know what we are doing here so just ignore the dependency warning about 'formatMessage' + // no, we don't want to refetch the registry if stripes changes // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From 7dc0e1d22e76593b17aba2de465bbdc143bfe890 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 19 Nov 2025 08:25:53 -0600 Subject: [PATCH 18/28] only import/re-export context/initial values from shared-context --- src/ModulesContext.js | 6 +++++- src/components/MainNav/AppOrderProvider.js | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ModulesContext.js b/src/ModulesContext.js index 0c5b57d15..2de5ea159 100644 --- a/src/ModulesContext.js +++ b/src/ModulesContext.js @@ -1 +1,5 @@ -export { ModulesContext, modulesInitialState, useModules } from '@folio/stripes-shared-context'; +import { useContext } from 'react'; +import { ModulesContext } from '@folio/stripes-shared-context'; + +export const useModules = () => useContext(ModulesContext); +export { ModulesContext, modulesInitialValue } from '@folio/stripes-shared-context'; diff --git a/src/components/MainNav/AppOrderProvider.js b/src/components/MainNav/AppOrderProvider.js index 8eeaa6bfe..8116d110a 100644 --- a/src/components/MainNav/AppOrderProvider.js +++ b/src/components/MainNav/AppOrderProvider.js @@ -4,7 +4,9 @@ import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; import isArray from 'lodash/isArray'; -import { LastVisitedContext, useModules, useStripes } from '@folio/stripes-shared-context'; +import { LastVisitedContext } from '@folio/stripes-shared-context'; +import { useStripes } from '../../StripesContext'; +import { useModules } from '../../ModulesContext'; import usePreferences from '../../hooks/usePreferences'; import { packageName } from '../../constants'; import settingsIcon from './settings.svg'; From 196c37a86f1b09449059cb7cba8614ab1e221f14 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Mon, 1 Dec 2025 08:51:18 -0600 Subject: [PATCH 19/28] switch away from shared context --- .stripesclirc.js | 4 ++-- package.json | 6 +++++- src/App.js | 2 +- src/CalloutContext.js | 4 +--- src/ModulesContext.js | 13 ++++++++++--- src/StripesContext.js | 4 +--- .../LastVisited/LastVisitedContext.js | 4 ++-- src/components/Login/LoginCtrl.js | 7 +++++-- src/components/MainNav/AppOrderProvider.js | 14 +++----------- .../MainNav/CurrentApp/AppCtxMenuContext.js | 3 +-- .../ModuleHierarchy/ModuleHierarchyContext.js | 4 +++- src/components/RegistryLoader.js | 17 ++++++++++++++++- 12 files changed, 50 insertions(+), 32 deletions(-) diff --git a/.stripesclirc.js b/.stripesclirc.js index 7b2e3fb61..d58c882bf 100644 --- a/.stripesclirc.js +++ b/.stripesclirc.js @@ -1,4 +1,4 @@ -const webpack = require('webpack'); +import webpack from 'webpack'; const miragePlugin = { // Standard yargs options object @@ -32,7 +32,7 @@ const miragePlugin = { } } -module.exports = { +export default { hasAllPerms: true, aliases: { diff --git a/package.json b/package.json index 0108dbd62..0ffe9c3fe 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "publishConfig": { "registry": "https://repository.folio.org/repository/npm-folio/" }, + "main": "index.js", + "type": "module", + "module": "dist/index.js", "scripts": { "start": "stripes serve", "test": "yarn run test:jest && yarn run test:bigtest ", @@ -94,6 +97,7 @@ "dependencies": { "@apollo/client": "^3.2.1", "@folio/stripes-shared-context": "^1.0.0", + "@module-federation/runtime": "^0.21.6", "classnames": "^2.2.5", "core-js": "^3.26.1", "final-form": "^4.18.2", @@ -137,4 +141,4 @@ "redux-observable": "^1.2.0", "rxjs": "^6.6.3" } -} +} \ No newline at end of file diff --git a/src/App.js b/src/App.js index 6d7e7429c..db89ff325 100644 --- a/src/App.js +++ b/src/App.js @@ -2,7 +2,6 @@ import React, { Component, StrictMode } from 'react'; import PropTypes from 'prop-types'; import { okapi as okapiConfig, config } from 'stripes-config'; import merge from 'lodash/merge'; -import { modulesInitialState } from '@folio/stripes-shared-context'; import AppConfigError from './components/AppConfigError'; import connectErrorEpic from './connectErrorEpic'; @@ -12,6 +11,7 @@ import configureStore from './configureStore'; import gatherActions from './gatherActions'; import { destroyStore } from './mainActions'; import { getModules } from './entitlementService'; +import { modulesInitialState } from './ModulesContext'; import css from './components/SessionEventContainer/style.css'; import Root from './components/Root'; diff --git a/src/CalloutContext.js b/src/CalloutContext.js index 66665e70f..b2b0f180c 100644 --- a/src/CalloutContext.js +++ b/src/CalloutContext.js @@ -1,8 +1,6 @@ import React, { useContext } from 'react'; -import { CalloutContext } from '@folio/stripes-shared-context'; - -export { CalloutContext }; +export const CalloutContext = React.createContext(); export const useCallout = () => { return useContext(CalloutContext); diff --git a/src/ModulesContext.js b/src/ModulesContext.js index 2de5ea159..d113f8343 100644 --- a/src/ModulesContext.js +++ b/src/ModulesContext.js @@ -1,5 +1,12 @@ -import { useContext } from 'react'; -import { ModulesContext } from '@folio/stripes-shared-context'; +import React, { useContext } from 'react'; +export const modulesInitialState = { + app: [], + handler: [], + plugin: [], + settings: [], +}; + +export const ModulesContext = React.createContext(modulesInitialState); +export default ModulesContext; export const useModules = () => useContext(ModulesContext); -export { ModulesContext, modulesInitialValue } from '@folio/stripes-shared-context'; diff --git a/src/StripesContext.js b/src/StripesContext.js index 16c062389..835262268 100644 --- a/src/StripesContext.js +++ b/src/StripesContext.js @@ -2,9 +2,7 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import hoistNonReactStatics from 'hoist-non-react-statics'; -import { StripesContext } from '@folio/stripes-shared-context'; - -export { StripesContext }; +export const StripesContext = React.createContext(); function getDisplayName(WrappedComponent) { return WrappedComponent.displayName || WrappedComponent.name || 'Component'; diff --git a/src/components/LastVisited/LastVisitedContext.js b/src/components/LastVisited/LastVisitedContext.js index e7c9f0f84..166e08b4a 100644 --- a/src/components/LastVisited/LastVisitedContext.js +++ b/src/components/LastVisited/LastVisitedContext.js @@ -1,3 +1,3 @@ -import { LastVisitedContext } from '@folio/stripes-shared-context'; +import React from 'react'; -export default LastVisitedContext; +export default React.createContext({}); diff --git a/src/components/Login/LoginCtrl.js b/src/components/Login/LoginCtrl.js index 85b3cdf13..84a07b968 100644 --- a/src/components/Login/LoginCtrl.js +++ b/src/components/Login/LoginCtrl.js @@ -5,6 +5,8 @@ import { withRouter, matchPath, } from 'react-router-dom'; +import { okapi } from 'stripes-config'; + import { ConnectContext } from '@folio/stripes-connect'; import { @@ -15,6 +17,8 @@ import { import { setAuthError } from '../../okapiActions'; import Login from './Login'; + + class LoginCtrl extends Component { static propTypes = { authFailure: PropTypes.arrayOf(PropTypes.object), @@ -37,8 +41,7 @@ class LoginCtrl extends Component { constructor(props) { super(props); - this.sys = require('stripes-config'); // eslint-disable-line global-require - this.tenant = this.sys.okapi.tenant; + this.tenant = okapi.tenant; if (props.autoLogin && props.autoLogin.username) { this.handleSubmit(props.autoLogin); } diff --git a/src/components/MainNav/AppOrderProvider.js b/src/components/MainNav/AppOrderProvider.js index 8116d110a..4b790da10 100644 --- a/src/components/MainNav/AppOrderProvider.js +++ b/src/components/MainNav/AppOrderProvider.js @@ -2,11 +2,10 @@ import { createContext, useContext, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; -import isArray from 'lodash/isArray'; -import { LastVisitedContext } from '@folio/stripes-shared-context'; import { useStripes } from '../../StripesContext'; import { useModules } from '../../ModulesContext'; +import { LastVisitedContext } from '../LastVisited'; import usePreferences from '../../hooks/usePreferences'; import { packageName } from '../../constants'; import settingsIcon from './settings.svg'; @@ -40,11 +39,11 @@ export const AppOrderContext = createContext({ * Function to update the preference. Accepts an list of objects with shape: * { name: string - the module package name, sans scope and `ui-` prefix } */ - updateList: () => { }, + updateList: () => {}, /** * Function to delete any the app order preference and reset the list. */ - reset: () => { }, + reset: () => {}, }); // hook for AppOrderContext consumption. @@ -104,13 +103,6 @@ function getAllowedApps(appModules, stripes, pathname, lastVisited, formatMessag route: SETTINGS_ROUTE }); } - - // use translated displayName rather that ast object; - apps.forEach((app) => { - if (isArray(app.displayName)) { - app.displayName = app.displayName[0].value; - } - }); return apps.toSorted((a, b) => a.displayName.localeCompare(b.displayName)); } diff --git a/src/components/MainNav/CurrentApp/AppCtxMenuContext.js b/src/components/MainNav/CurrentApp/AppCtxMenuContext.js index 9327f1769..791887881 100644 --- a/src/components/MainNav/CurrentApp/AppCtxMenuContext.js +++ b/src/components/MainNav/CurrentApp/AppCtxMenuContext.js @@ -1,8 +1,7 @@ import React from 'react'; import hoistNonReactStatics from 'hoist-non-react-statics'; -import { AppCtxMenuContext } from '@folio/stripes-shared-context'; -export { AppCtxMenuContext }; +export const AppCtxMenuContext = React.createContext(); export function withAppCtxMenu(Component) { const WrappedComponent = (props) => { diff --git a/src/components/ModuleHierarchy/ModuleHierarchyContext.js b/src/components/ModuleHierarchy/ModuleHierarchyContext.js index 1a5c4d177..265f1101b 100644 --- a/src/components/ModuleHierarchy/ModuleHierarchyContext.js +++ b/src/components/ModuleHierarchy/ModuleHierarchyContext.js @@ -1,3 +1,5 @@ -import { ModuleHierarchyContext } from '@folio/stripes-shared-context'; +import React from 'react'; + +const ModuleHierarchyContext = React.createContext(); export default ModuleHierarchyContext; diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js index c0426e5bd..56e9eabd5 100644 --- a/src/components/RegistryLoader.js +++ b/src/components/RegistryLoader.js @@ -4,6 +4,7 @@ import { okapi } from 'stripes-config'; import { ModulesContext } from '../ModulesContext'; import loadRemoteComponent from '../loadRemoteComponent'; +import { loadRemote, registerRemotes } from '@module-federation/runtime'; /** * preloadModules @@ -21,9 +22,14 @@ const preloadModules = async (remotes) => { try { const loaderArray = []; + const remotesToRegister = remotes.map(remote => ({ + name: remote.name, entry: remote.entry + })); + registerRemotes(remotesToRegister); remotes.forEach(async remote => { const { name, url } = remote; - loaderArray.push(loadRemoteComponent(url, name) + + loaderArray.push(loadRemote(name) .then((module) => { remote.getModule = () => module.default; })); @@ -180,6 +186,14 @@ const RegistryLoader = ({ stripes, children }) => { // read the list of registered apps from the registry, useEffect(() => { + // ENABLE MOD FED DEBUGGING + localStorage.setItem('FEDERATION_DEBUG', 'true'); + + const fetchMFStats = async () => { + const stats = await fetch(`${location.protocol}//${location.host}/mf-stats.json`).then((response) => response.json()); + stripes.logger.log('core', 'Module Federation Stats:', stats); + }; + const fetchRegistry = async () => { // read the list of registered apps const registry = await fetch(okapi.registryUrl).then((response) => response.json()); @@ -197,6 +211,7 @@ const RegistryLoader = ({ stripes, children }) => { setModules(cachedModules); }; + fetchMFStats(); fetchRegistry(); // no, we don't want to refetch the registry if stripes changes // eslint-disable-next-line react-hooks/exhaustive-deps From 03ac478ffc25549ba2f493eeba5659b3240b49a1 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 6 Feb 2026 15:46:20 -0600 Subject: [PATCH 20/28] remove pre-preloading modules code --- src/AppRoutes.js | 71 ++++++++++++++--------------- src/RootWithIntl.js | 5 +- src/components/EntitlementLoader.js | 6 ++- src/components/index.js | 2 + 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/AppRoutes.js b/src/AppRoutes.js index 2158a701d..ecd2f591f 100644 --- a/src/AppRoutes.js +++ b/src/AppRoutes.js @@ -1,4 +1,4 @@ -import React, { useMemo, Suspense } from 'react'; +import { Suspense, useMemo } from 'react'; import { Route } from 'react-router-dom'; import PropTypes from 'prop-types'; @@ -12,7 +12,6 @@ import { invokeEventHandlers } from './handlerService'; import { packageName } from './constants'; import { ModuleHierarchyProvider } from './components'; import events from './events'; -import loadRemoteComponent from './loadRemoteComponent'; // Process and cache "app" type modules and render the routes const AppRoutes = ({ modules, stripes }) => { @@ -24,13 +23,11 @@ const AppRoutes = ({ modules, stripes }) => { const perm = `module.${name}.enabled`; if (!stripes.hasPerm(perm)) return null; - const RemoteComponent = React.lazy(() => loadRemoteComponent(module.url, module.name)); const connect = connectFor(module.module, stripes.epics, stripes.logger); let ModuleComponent; - try { - ModuleComponent = connect(RemoteComponent); + ModuleComponent = connect(module.getModule()); } catch (error) { console.error(error); // eslint-disable-line throw Error(error); @@ -51,41 +48,39 @@ const AppRoutes = ({ modules, stripes }) => { }, [modules.app, stripes]); return cachedModules.map(({ ModuleComponent, connect, module, name, moduleStripes, stripes: propsStripes, displayName }) => ( - }> - { - const data = { displayName, name }; + { + const data = { displayName, name }; - // allow SELECT_MODULE handlers to intervene - const handlerComponents = invokeEventHandlers(events.SELECT_MODULE, moduleStripes, modules.handler, data); - if (handlerComponents.length) { - return handlerComponents.map(Handler => ()); - } + // allow SELECT_MODULE handlers to intervene + const handlerComponents = invokeEventHandlers(events.SELECT_MODULE, moduleStripes, modules.handler, data); + if (handlerComponents.length) { + return handlerComponents.map(Handler => ()); + } - return ( - - -
- - - }> - - - - -
-
-
- ); - }} - /> -
+ return ( + + +
+ + + }> + + + + +
+
+
+ ); + }} + /> )); }; diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 0f5ecfcbc..3c7317550 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -18,6 +18,7 @@ import { MainContainer, MainNav, ModuleContainer, + ModuleTranslator, TitledRoute, Front, OIDCRedirect, @@ -201,8 +202,8 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut - -
+ + ); }; diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 98bf74f4e..37552fe05 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { useStripes } from '../StripesContext'; import { ModulesContext, useModules, modulesInitialState } from '../ModulesContext'; import { loadEntitlement } from './loadEntitlement'; +import { registerRemotes, loadRemote } from '@module-federation/runtime'; /** * preloadModules @@ -195,6 +196,7 @@ const EntitlementLoader = ({ children }) => { // if the signal is aborted, avoid all subsequent fetches, state updates... if (!signal.aborted) { + fetchMFStats(); try { // load module assets (translations, icons)... remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); @@ -203,7 +205,7 @@ const EntitlementLoader = ({ children }) => { } const remotesToRegister = remotes.map(remote => ({ - name: remote.name, entry: remote.entry + name: remote.name, entry: remote.location })); registerRemotes(remotesToRegister); @@ -217,7 +219,7 @@ const EntitlementLoader = ({ children }) => { setRemoteModules(cachedModules); } }; - fetchMFStats(); + fetchRegistry(); } return () => { diff --git a/src/components/index.js b/src/components/index.js index 639dcecf1..3d6fc3547 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -13,8 +13,10 @@ export { default as QueryStateUpdater } from './MainNav/QueryStateUpdater'; export { AppOrderProvider } from './MainNav/AppOrderProvider'; export { default as ModuleContainer } from './ModuleContainer'; export { withModule, withModules } from './Modules'; +export { default as ModuleTranslator } from './ModuleTranslator'; export { default as OrganizationLogo } from './OrganizationLogo'; export { default as OverlayContainer } from './OverlayContainer'; +export { default as Root } from './Root'; export { default as SSOLogin } from './SSOLogin'; export { default as SystemSkeleton } from './SystemSkeleton'; export { default as TitledRoute } from './TitledRoute'; From f39a47142a187fa407dc77c6e4cad0107f54cbec Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 6 Feb 2026 15:49:33 -0600 Subject: [PATCH 21/28] remove registryLoader --- src/components/RegistryLoader.js | 237 ------------------------------- 1 file changed, 237 deletions(-) delete mode 100644 src/components/RegistryLoader.js diff --git a/src/components/RegistryLoader.js b/src/components/RegistryLoader.js deleted file mode 100644 index 56e9eabd5..000000000 --- a/src/components/RegistryLoader.js +++ /dev/null @@ -1,237 +0,0 @@ -import { useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; -import { okapi } from 'stripes-config'; - -import { ModulesContext } from '../ModulesContext'; -import loadRemoteComponent from '../loadRemoteComponent'; -import { loadRemote, registerRemotes } from '@module-federation/runtime'; - -/** - * preloadModules - * Loads each module code and sets up its getModule function. - * Map the list of applications to a hash keyed by acts-as type (app, plugin, - * settings, handler) where the value of each is an array of corresponding - * applications. - * - * @param {array} remotes - * @returns {app: [], plugin: [], settings: [], handler: []} - */ - -const preloadModules = async (remotes) => { - const modules = { app: [], plugin: [], settings: [], handler: [] }; - - try { - const loaderArray = []; - const remotesToRegister = remotes.map(remote => ({ - name: remote.name, entry: remote.entry - })); - registerRemotes(remotesToRegister); - remotes.forEach(async remote => { - const { name, url } = remote; - - loaderArray.push(loadRemote(name) - .then((module) => { - remote.getModule = () => module.default; - })); - }); - await Promise.all(loaderArray); - remotes.forEach((remote) => { - const { actsAs } = remote; - actsAs.forEach(type => modules[type].push({ ...remote })); - }); - } catch (e) { - console.error('Error parsing modules from registry', e); - } - - return modules; -}; - -/** - * loadTranslations - * return a promise that fetches translations for the given module, - * dispatches setLocale, and then returns the translations. - * - * @param {object} stripes - * @param {object} module info read from the registry - * - * @returns {Promise} - */ -const loadTranslations = (stripes, module) => { - // construct a fully-qualified URL to load. - // - // locale strings include a name plus optional region and numbering system. - // we only care about the name and region. this stripes the numberin system - // and converts from kebab-case (the IETF standard) to snake_case (which we - // somehow adopted for our files in Lokalise). - const locale = stripes.locale.split('-u-nu-')[0].replace('-', '_'); - const url = `${module.host}:${module.port}/translations/${locale}.json`; - stripes.logger.log('core', `loading ${locale} translations for ${module.name}`); - - return fetch(url) - .then((response) => { - if (response.ok) { - return response.json().then((translations) => { - // 1. translation entries look like "key: val"; we want "ui-${app}.key: val" - // 2. module.name is snake_case (I have no idea why); we want kebab-case - // const prefix = module.name.replace('folio_', 'ui-').replaceAll('_', '-'); - // const keyed = []; - // Object.keys(translations).forEach(key => { - // keyed[`${prefix}.${key}`] = translations[key]; - // }); - - const tx = { ...stripes.okapi.translations, ...translations }; - - // stripes.store.dispatch(setTranslations(tx)); - - // const tx = { ...stripes.okapi.translations, ...keyed }; - // console.log(`filters.status.active: ${tx['ui-users.filters.status.active']}`) - stripes.setLocale(stripes.locale, tx); - return tx; - }); - } else { - throw new Error(`Could not load translations for ${module}`); - } - }); -}; - -/** - * loadIcons - * Register remotely-hosted icons with stripes by dispatching addIcon - * for each element of the module's icons array. - * - * @param {object} stripes - * @param {object} module info read from the registry - * - * @returns {void} - */ -const loadIcons = (stripes, module) => { - if (module.icons && module.icons.length) { - stripes.logger.log('core', `loading icons for ${module.module}`); - module.icons.forEach(i => { - stripes.logger.log('core', ` > ${i.name}`); - - const icon = { - [i.name]: { - src: `${module.host}:${module.port}/icons/${i.name}.svg`, - alt: i.title, - } - }; - stripes.addIcon(module.module, icon); - }); - } -}; - -/** - * loadModuleAssets - * Load a module's icons, translations, and sounds. - * @param {object} stripes - * @param {object} module info read from the registry - * @returns {} copy of the module, plus the key `displayName` containing its localized name - */ -const loadModuleAssets = (stripes, module) => { - // register icons - loadIcons(stripes, module); - - // register sounds - // TODO loadSounds(stripes, module); - - // register translations - return loadTranslations(stripes, module) - .then((tx) => { - // tx[module.displayName] instead of formatMessage({ id: module.displayName}) - // because updating store is async and we don't have the updated values quite yet... - // - // when translations are compiled, the value of the tx[module.displayName] is an array - // containing a single object with shape { type: 'messageFormatPattern', value: 'the actual string' } - // so we have to extract the value from that structure. - let newDisplayName; - if (module.displayName) { - if (typeof tx[module.displayName] === 'string') { - newDisplayName = tx[module.displayName]; - } else { - newDisplayName = tx[module.displayName][0].value; - } - } - - return { - ...module, - displayName: module.displayName ? - newDisplayName : module.module, - }; - }) - .catch(e => { - // eslint-disable-next-line no-console - console.error(e); - }); -}; - -/** - * loadAllModuleAssets - * Loads icons, translations, and sounds for all modules. Inserts the correct 'displayName' for each module. - * @param {props} - * @returns Promise - */ -const loadAllModuleAssets = async (stripes, remotes) => { - return Promise.all(remotes.map((r) => loadModuleAssets(stripes, r))); -}; - -/** - * Registry Loader - * @param {object} stripes - * @param {*} children - * @returns - */ -const RegistryLoader = ({ stripes, children }) => { - const [modules, setModules] = useState(); - - // read the list of registered apps from the registry, - useEffect(() => { - // ENABLE MOD FED DEBUGGING - localStorage.setItem('FEDERATION_DEBUG', 'true'); - - const fetchMFStats = async () => { - const stats = await fetch(`${location.protocol}//${location.host}/mf-stats.json`).then((response) => response.json()); - stripes.logger.log('core', 'Module Federation Stats:', stats); - }; - - const fetchRegistry = async () => { - // read the list of registered apps - const registry = await fetch(okapi.registryUrl).then((response) => response.json()); - - // remap registry from an object shaped like { key1: app1, key2: app2, ...} - // to an array shaped like [ { name: key1, ...app1 }, { name: key2, ...app2 } ...] - const remotes = Object.entries(registry.remotes).map(([name, metadata]) => ({ name, ...metadata })); - - // load module assets (translations, icons), then load modules... - const remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); - // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. - const cachedModules = await preloadModules(remotesWithLoadedAssets); - - // prefetch - setModules(cachedModules); - }; - - fetchMFStats(); - fetchRegistry(); - // no, we don't want to refetch the registry if stripes changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - {modules ? children : null} - - ); -}; - -RegistryLoader.propTypes = { - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node, - PropTypes.func, - ]), - stripes: PropTypes.object.isRequired, -}; - - -export default RegistryLoader; From a2bf44fa01347ebd81966f09423d26c017509cf7 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Fri, 6 Feb 2026 15:55:06 -0600 Subject: [PATCH 22/28] restore Root.js from main --- src/components/Root/Root.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 22ce886cb..f88e884d3 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -29,6 +29,11 @@ import './Root.css'; import { FFetch } from './FFetch'; +if (!metadata) { + // eslint-disable-next-line no-console + console.error('No metadata harvested from package files, so you will not get app icons. Probably the stripes-core in your Stripes CLI is too old. Try `yarn global upgrade @folio/stripes-cli`'); +} + class Root extends Component { constructor(...args) { super(...args); @@ -218,7 +223,7 @@ class Root extends Component { - + Date: Mon, 9 Feb 2026 09:23:14 -0600 Subject: [PATCH 23/28] add debug info to EntitlementLoader --- src/components/EntitlementLoader.js | 3 ++- src/components/loadEntitlement.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 37552fe05..3339fd1b5 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -1,9 +1,10 @@ import { useEffect, useState, useMemo } from 'react'; import PropTypes from 'prop-types'; +import { registerRemotes, loadRemote } from '@module-federation/runtime'; import { useStripes } from '../StripesContext'; import { ModulesContext, useModules, modulesInitialState } from '../ModulesContext'; import { loadEntitlement } from './loadEntitlement'; -import { registerRemotes, loadRemote } from '@module-federation/runtime'; + /** * preloadModules diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index 33ef9a139..326269d4c 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -4,7 +4,7 @@ import { stripesHubAPI } from '../constants'; export const loadEntitlement = async (discoveryUrl, signal) => { let registry = {}; const discovery = await localforage.getItem(stripesHubAPI.REMOTE_LIST_KEY); - if (discovery) { + if (discovery && discovery.length !== 0) { registry = { discovery }; } else if (discoveryUrl) { try { From f9a47cf2b6b0c817fe6283154bc55dabf8c84a01 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 10 Feb 2026 08:18:39 -0600 Subject: [PATCH 24/28] remove mod-fed debug code --- src/components/EntitlementLoader.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 3339fd1b5..5d8648812 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -23,7 +23,7 @@ export const preloadModules = async (stripes, remotes) => { try { const loaderArray = []; remotes.forEach(remote => { - const { name, location } = remote; + const { name } = remote; loaderArray.push(loadRemote(name) .then((module) => { remote.getModule = () => module.default; @@ -173,14 +173,6 @@ const EntitlementLoader = ({ children }) => { const controller = new AbortController(); const signal = controller.signal; if (okapi?.discoveryUrl) { - // ENABLE MOD FED DEBUGGING - localStorage.setItem('FEDERATION_DEBUG', 'true'); - - const fetchMFStats = async () => { - const stats = await fetch(`${location.protocol}//${location.host}/mf-stats.json`).then((response) => response.json()); - stripes.logger.log('core', 'Module Federation Stats:', stats); - }; - // fetches the list of registered apps/metadata, // loads icons and translations, then module code, // ultimately stores the result in the modules state to pass down into the modules context. @@ -197,7 +189,6 @@ const EntitlementLoader = ({ children }) => { // if the signal is aborted, avoid all subsequent fetches, state updates... if (!signal.aborted) { - fetchMFStats(); try { // load module assets (translations, icons)... remotesWithLoadedAssets = await loadAllModuleAssets(stripes, remotes); From 839ef87378b868b8fd332d20d4d66da8bbd85262 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Tue, 10 Feb 2026 13:18:02 -0600 Subject: [PATCH 25/28] remove loadRemoteComponent and tests, replace with mod-fed runtime package. --- src/components/EntitlementLoader.js | 4 ++ src/components/loadEntitlement.js | 2 +- src/loadRemoteComponent.js | 39 -------------- .../bigtest/tests/LoadRemoteComponent-test.js | 51 ------------------- 4 files changed, 5 insertions(+), 91 deletions(-) delete mode 100644 src/loadRemoteComponent.js delete mode 100644 test/bigtest/tests/LoadRemoteComponent-test.js diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index 5d8648812..ffd680265 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -173,6 +173,10 @@ const EntitlementLoader = ({ children }) => { const controller = new AbortController(); const signal = controller.signal; if (okapi?.discoveryUrl) { + // ENABLE MOD FED DEBUGGING + localStorage.setItem('FEDERATION_DEBUG', 'true'); + + // fetches the list of registered apps/metadata, // loads icons and translations, then module code, // ultimately stores the result in the modules state to pass down into the modules context. diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index 326269d4c..38dd7d4c0 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -39,7 +39,7 @@ export const loadEntitlement = async (discoveryUrl, signal) => { remote.origin = url.origin; const segments = url.href.split('/'); segments.pop(); - const hrefWithoutFilename = segments.join('/') + const hrefWithoutFilename = segments.join('/'); remote.assetPath = hrefWithoutFilename; }); return Promise.resolve(registry?.discovery); diff --git a/src/loadRemoteComponent.js b/src/loadRemoteComponent.js deleted file mode 100644 index 6c9c7a053..000000000 --- a/src/loadRemoteComponent.js +++ /dev/null @@ -1,39 +0,0 @@ -// injects a script tag to load a remote module. -// This has to be performed in this way for publicPath of the federated remote -// to be automatically discovered since it works based on document.currentScript.src. -// Once the script is loaded, it executes webpack module federation API -// to initialize sharing and retrieve the exposed module. - -function injectScript(remoteUrl, remoteName) { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = remoteUrl; - script.onload = async () => { - const container = window[remoteName]; - - // eslint-disable-next-line no-undef - await __webpack_init_sharing__('default'); - - // eslint-disable-next-line no-undef - await container.init(__webpack_share_scopes__.default); - - const factory = await container.get('./MainEntry'); - const Module = await factory(); - resolve(Module); - }; - script.onerror = () => { - reject(new Error(`Failed to load remote script from ${remoteUrl}`)); - }; - document.body.appendChild(script); - }); -} - -export default async function loadRemoteComponent(remoteUrl, remoteName) { - try { - const Module = await injectScript(remoteUrl, remoteName); - return Module; - } catch (error) { - console.error(error); - throw error; - } -} diff --git a/test/bigtest/tests/LoadRemoteComponent-test.js b/test/bigtest/tests/LoadRemoteComponent-test.js deleted file mode 100644 index 396190418..000000000 --- a/test/bigtest/tests/LoadRemoteComponent-test.js +++ /dev/null @@ -1,51 +0,0 @@ -import { beforeEach, it, afterEach, describe } from 'mocha'; -import { expect } from 'chai'; -import { createServer, Response } from 'miragejs'; - -import loadRemoteComponent from '../../../src/loadRemoteComponent'; - -describe.only('loadRemoteComponent', () => { - let server; - const mockRemoteUrl = '/example/testRemote/remoteEntry.js'; - const mockErrorUrl = 'https://example.com/nonexistent/remoteEntry.js'; - - const mockRemoteName = 'testComponent'; - - beforeEach(async function () { - server = createServer({ environment: 'test' }); - server.get(mockRemoteUrl, () => { - const mockScriptContent = `window['${mockRemoteName}'] = { - init: function() { console.log("Component initialized"); }, - get: function() { return function() { return { default: 'I am a module' }; }} - }; - `; - - // return mockScriptContent; - return mockScriptContent; - }); - - server.get(mockErrorUrl, () => (server.serialize({ ok: false }))); - }); - - afterEach(function () { - server?.shutdown(); - server = null; - delete window[mockRemoteName]; - }); - - it('should inject the script tag with the requested src attribute', async () => { - try { - await loadRemoteComponent(mockRemoteUrl, mockRemoteName); - } catch (error) { - expect(Array.from(document.querySelectorAll('script')).find(scr => scr.src === mockRemoteUrl)).to.not.be.null; - } - }); - - it('should handle errors when loading the remote script', async () => { - try { - await loadRemoteComponent(mockErrorUrl, mockRemoteName); - } catch (error) { - expect(error.message).to.equal(`Failed to load remote script from ${mockErrorUrl}`); - } - }); -}); From da09382f29f577541ec299a7fe25554f5782abbc Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 11 Feb 2026 16:06:45 -0600 Subject: [PATCH 26/28] use single instance of mf runtime --- src/components/EntitlementLoader.js | 7 ++++--- src/components/loadEntitlement.js | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index ffd680265..dbffd0f16 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -1,6 +1,6 @@ import { useEffect, useState, useMemo } from 'react'; import PropTypes from 'prop-types'; -import { registerRemotes, loadRemote } from '@module-federation/runtime'; +import { getInstance } from '@module-federation/runtime'; import { useStripes } from '../StripesContext'; import { ModulesContext, useModules, modulesInitialState } from '../ModulesContext'; import { loadEntitlement } from './loadEntitlement'; @@ -24,7 +24,7 @@ export const preloadModules = async (stripes, remotes) => { const loaderArray = []; remotes.forEach(remote => { const { name } = remote; - loaderArray.push(loadRemote(name) + loaderArray.push(getInstance().loadRemote(name) .then((module) => { remote.getModule = () => module.default; }) @@ -203,7 +203,8 @@ const EntitlementLoader = ({ children }) => { const remotesToRegister = remotes.map(remote => ({ name: remote.name, entry: remote.location })); - registerRemotes(remotesToRegister); + + getInstance().registerRemotes(remotesToRegister); try { // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. diff --git a/src/components/loadEntitlement.js b/src/components/loadEntitlement.js index 38dd7d4c0..11fc513b1 100644 --- a/src/components/loadEntitlement.js +++ b/src/components/loadEntitlement.js @@ -18,7 +18,7 @@ export const loadEntitlement = async (discoveryUrl, signal) => { // it's present...) registry.discovery = registryData?.discovery.filter((entry) => entry.name !== stripesHubAPI.HOST_APP_NAME); - await localforage.setItem(stripesHubAPI.REMOTE_LIST_KEY, registry.discovery); + // await localforage.setItem(stripesHubAPI.REMOTE_LIST_KEY, registry.discovery); } catch (e) { if (e.name !== 'AbortError') { console.error('Discovery fetch error:', e); // eslint-disable-line no-console From 6e1e3fa8fb30e1d29c1c04574c7d5455bd9c3b86 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 12 Feb 2026 23:17:30 -0600 Subject: [PATCH 27/28] use latest version of mod-fed --- package.json | 2 +- src/components/EntitlementLoader.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0d57b1623..2f7c67253 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ }, "dependencies": { "@apollo/client": "^3.2.1", - "@module-federation/runtime": "^0.21.6", + "@module-federation/runtime": "^2.0.0", "classnames": "^2.2.5", "core-js": "^3.26.1", "final-form": "^4.18.2", diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index dbffd0f16..bbacf956f 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -17,6 +17,7 @@ import { loadEntitlement } from './loadEntitlement'; * @param {array} remotes * @returns {app: [], plugin: [], settings: [], handler: []} */ + export const preloadModules = async (stripes, remotes) => { const modules = { app: [], plugin: [], settings: [], handler: [] }; @@ -208,7 +209,7 @@ const EntitlementLoader = ({ children }) => { try { // load module code - this loads each module only once and up `getModule` so that it can be used sychronously. - cachedModules = await preloadModules(stripes, remotesWithLoadedAssets); + cachedModules = await preloadModules(stripes, remotesWithLoadedAssets, remotesToRegister); } catch (e) { handleRemoteModuleError(stripes, `error loading remote modules: ${e}`); } From 48389deb26cb7e0d46b970df0b1c56f57906628d Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 19 Feb 2026 07:55:05 -0600 Subject: [PATCH 28/28] load remote by the exposed name --- src/components/EntitlementLoader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/EntitlementLoader.js b/src/components/EntitlementLoader.js index bbacf956f..a359f5c5f 100644 --- a/src/components/EntitlementLoader.js +++ b/src/components/EntitlementLoader.js @@ -25,7 +25,7 @@ export const preloadModules = async (stripes, remotes) => { const loaderArray = []; remotes.forEach(remote => { const { name } = remote; - loaderArray.push(getInstance().loadRemote(name) + loaderArray.push(getInstance().loadRemote(`${name}/MainEntry`) .then((module) => { remote.getModule = () => module.default; })