Fix race condition with plugin bundles

There may be a race condition when loading plugin bundles with lazy dependencies:

The OpenAPI plugin depends on "redux" and "react-redux", which are bundled in the lazy "ui-legacy" module, as the usage of redux is deprecated in the scmm. The "ui-legacy" module also binds a global wrapper extension point around the whole app. Due to a bug in the plugin loader, plugin bundles were marked as successfully loaded even if a lazy dependency hadn't successfully loaded yet. This caused the extension point from the "ui-legacy" bundle to be bound after the initial render. As the process of extension point binding  doesn't trigger a re-render, the redux provider was not wrapped around the app on initial load. When the user now moved focus out of and back into the window, react-query hooks automatically refetched e.g. the index links, which caused a re-render. Now with the bound extension point applied. This caused the whole app to be unmounted and re-mounted, which in turn reset all form fields anywhere below in the tree.

Also fixes a bug where the global notifications component was executing a state update while already unmounted.

Also fixes a bug in the user creation form where an object literal was passed to the form's default values which caused a form reset whenever the component re-rendered.

Committed-by: Rene Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2023-05-15 17:34:50 +02:00
parent 8025e82b1b
commit 01bff1ce95
6 changed files with 169 additions and 65 deletions

View File

@@ -0,0 +1,6 @@
- type: fixed
description: Forms randomly resetting when OpenAPI plugin is installed
- type: fixed
description: React error in global notifications
- type: fixed
description: User creation form resetting on re-render

View File

@@ -28,6 +28,7 @@ import { Link, Notification, NotificationCollection } from "@scm-manager/ui-type
import { apiClient } from "./apiclient"; import { apiClient } from "./apiclient";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { requiredLink } from "./links"; import { requiredLink } from "./links";
import { useCancellablePromise } from "./utils";
export const useNotifications = () => { export const useNotifications = () => {
const { data: me } = useMe(); const { data: me } = useMe();
@@ -95,11 +96,13 @@ export const useNotificationSubscription = (
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [disconnectedAt, setDisconnectedAt] = useState<Date>(); const [disconnectedAt, setDisconnectedAt] = useState<Date>();
const link = (notificationCollection?._links.subscribe as Link)?.href; const link = (notificationCollection?._links.subscribe as Link)?.href;
const cancelOnUnmount = useCancellablePromise();
const onVisible = useCallback(() => { const onVisible = useCallback(() => {
// we don't need to catch the error, // we don't need to catch the error,
// because if the refetch throws an error the parent useNotifications should catch it // because if the refetch throws an error the parent useNotifications should catch it
refetch().then((collection) => { cancelOnUnmount(refetch()).then(
(collection) => {
if (collection) { if (collection) {
const newNotifications = collection._embedded?.notifications.filter((n) => { const newNotifications = collection._embedded?.notifications.filter((n) => {
return disconnectedAt && disconnectedAt < new Date(n.createdAt); return disconnectedAt && disconnectedAt < new Date(n.createdAt);
@@ -109,8 +112,14 @@ export const useNotificationSubscription = (
} }
setDisconnectedAt(undefined); setDisconnectedAt(undefined);
} }
}); },
}, [disconnectedAt, refetch]); (reason) => {
if (!reason.isCanceled) {
throw reason;
}
}
);
}, [cancelOnUnmount, disconnectedAt, refetch]);
const onHide = useCallback(() => { const onHide = useCallback(() => {
setDisconnectedAt(new Date()); setDisconnectedAt(new Date());

View File

@@ -22,8 +22,44 @@
* SOFTWARE. * SOFTWARE.
*/ */
import { useCallback, useEffect, useRef } from "react";
export const createQueryString = (params: Record<string, string>) => { export const createQueryString = (params: Record<string, string>) => {
return Object.keys(params) return Object.keys(params)
.map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(params[k])) .map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(params[k]))
.join("&"); .join("&");
}; };
export type CancelablePromise<T> = Promise<T> & { cancel: () => void };
export function makeCancelable<T>(promise: Promise<T>): CancelablePromise<T> {
let isCanceled = false;
const wrappedPromise = new Promise<T>((resolve, reject) => {
promise
.then((val) => (isCanceled ? reject({ isCanceled }) : resolve(val)))
.catch((error) => (isCanceled ? reject({ isCanceled }) : reject(error)));
});
return Object.assign(wrappedPromise, {
cancel() {
isCanceled = true;
},
});
}
export function useCancellablePromise() {
const promises = useRef<Array<CancelablePromise<unknown>>>();
useEffect(() => {
promises.current = promises.current || [];
return function cancel() {
promises.current?.forEach((p) => p.cancel());
promises.current = [];
};
}, []);
return useCallback(<T>(p: Promise<T>) => {
const cPromise = makeCancelable(p);
promises.current?.push(cPromise);
return cPromise;
}, []);
}

View File

@@ -36,6 +36,9 @@ class ModuleResolutionError extends Error {
const modules: { [name: string]: unknown } = {}; const modules: { [name: string]: unknown } = {};
const lazyModules: { [name: string]: () => Promise<unknown> } = {}; const lazyModules: { [name: string]: () => Promise<unknown> } = {};
const queue: { [name: string]: Module } = {}; const queue: { [name: string]: Module } = {};
const bundleLoaderPromises: {
[name: string]: { resolve: (module: unknown) => void; reject: (reason?: unknown) => void };
} = {};
export const defineLazy = (name: string, cmp: () => Promise<unknown>) => { export const defineLazy = (name: string, cmp: () => Promise<unknown>) => {
lazyModules[name] = cmp; lazyModules[name] = cmp;
@@ -45,56 +48,108 @@ export const defineStatic = (name: string, cmp: unknown) => {
modules[name] = cmp; modules[name] = cmp;
}; };
const resolveModule = (name: string) => { /**
* Attempt to retrieve a module from the registry.
*
* If a lazy module is requested, it will be loaded and then returned after adding it to the registry.
*
* @see defineLazy
* @see defineStatic
* @see defineModule
* @throws {ModuleResolutionError} If the requested module cannot be found/loaded
*/
const resolveModule = async (name: string) => {
const module = modules[name]; const module = modules[name];
if (module) { if (module) {
return Promise.resolve(module); return module;
} }
const lazyModule = lazyModules[name]; const lazyModuleLoader = lazyModules[name];
if (lazyModule) { if (lazyModuleLoader) {
return lazyModule().then((mod: unknown) => { const lazyModule = await lazyModuleLoader();
modules[name] = mod; modules[name] = lazyModule;
return mod; return lazyModule;
});
} }
return Promise.reject(new ModuleResolutionError(name)); throw new ModuleResolutionError(name);
}; };
const defineModule = (name: string, module: Module) => { /**
Promise.all(module.dependencies.map(resolveModule)) * Executes a module and attempts to resolve all of its dependencies.
.then((resolvedDependencies) => { *
modules["@scm-manager/" + name] = module.fn(...resolvedDependencies); * If a dependency is not (yet) present, the module loading is deferred.
*
* Once a module on which the given module depends loaded successfully, it will
* kickstart another attempt at loading the given module.
*/
const defineModule = async (name: string, module: Module) => {
try {
const resolvedDependencies = await Promise.all(module.dependencies.map(resolveModule));
const loadedModuleName = "@scm-manager/" + name;
Object.keys(queue).forEach((queuedModuleName) => { // Store module to be used as dependency for other modules
const queueModule = queue[queuedModuleName]; modules[loadedModuleName] = module.fn(...resolvedDependencies);
// Resolve bundle in which module was defined
if (name in bundleLoaderPromises) {
bundleLoaderPromises[name].resolve(modules[loadedModuleName]);
delete bundleLoaderPromises[name];
}
// Executed queued modules that depend on the loaded module
for (const [queuedModuleName, queuedModule] of Object.entries(queue)) {
if (queuedModule.dependencies.includes(loadedModuleName)) {
delete queue[queuedModuleName]; delete queue[queuedModuleName];
defineModule(queuedModuleName, queueModule); defineModule(queuedModuleName, queuedModule);
}); }
}) }
.catch((e) => { } catch (reason) {
if (e instanceof ModuleResolutionError) { if (reason instanceof ModuleResolutionError) {
queue[name] = module; // Wait for module dependency to load
} else { queue[name] = module;
throw e; } else if (name in bundleLoaderPromises) {
// Forward error to bundle loader in which module was defined
bundleLoaderPromises[name].reject(reason);
delete bundleLoaderPromises[name];
} else {
// Throw unhandled exception
throw reason;
}
} }
});
}; };
/**
* This is attached to the global window scope and is automatically executed when a plugin module bundle is loaded.
*
* @see https://github.com/amdjs/amdjs-api/blob/master/AMD.md
*/
export const define = (name: string, dependencies: string[], fn: (...args: unknown[]) => Module) => { export const define = (name: string, dependencies: string[], fn: (...args: unknown[]) => Module) => {
defineModule(name, { dependencies, fn }); defineModule(name, { dependencies, fn });
}; };
export const load = (resource: string) => { /**
return new Promise((resolve, reject) => { * Asynchronously loads and executes a given resource bundle.
*
* If a module name is supplied, the bundle is expected to contain a single (AMD)[https://github.com/amdjs/amdjs-api/blob/master/AMD.md]
* module matching the provided module name.
*
* The promise will only resolve once the bundle loaded and, if it is a module,
* all dependencies are resolved and the module executed.
*/
export const load = (resource: string, moduleName?: string) =>
new Promise((resolve, reject) => {
const script = document.createElement("script"); const script = document.createElement("script");
script.src = resource; script.src = resource;
if (moduleName) {
bundleLoaderPromises[moduleName] = { resolve, reject };
} else {
script.onload = resolve; script.onload = resolve;
}
script.onerror = reject; script.onerror = reject;
const body = document.querySelector("body"); const body = document.querySelector("body");
body?.appendChild(script); body?.appendChild(script);
body?.removeChild(script); body?.removeChild(script);
}); });
};

View File

@@ -28,6 +28,8 @@ import { apiClient, ErrorBoundary, ErrorNotification, Icon, Loading } from "@scm
import loadBundle from "./loadBundle"; import loadBundle from "./loadBundle";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { ExtensionPoint } from "@scm-manager/ui-extensions";
const isMainModuleBundle = (bundlePath: string, pluginName: string) => bundlePath.endsWith(`${pluginName}.bundle.js`);
type Props = { type Props = {
loaded: boolean; loaded: boolean;
children: ReactNode; children: ReactNode;
@@ -54,7 +56,7 @@ class PluginLoader extends React.Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
message: "booting" message: "booting",
}; };
} }
@@ -62,7 +64,7 @@ class PluginLoader extends React.Component<Props, State> {
const { loaded } = this.props; const { loaded } = this.props;
if (!loaded) { if (!loaded) {
this.setState({ this.setState({
message: "loading plugin information" message: "loading plugin information",
}); });
this.getPlugins(this.props.link); this.getPlugins(this.props.link);
@@ -72,16 +74,16 @@ class PluginLoader extends React.Component<Props, State> {
getPlugins = (link: string) => { getPlugins = (link: string) => {
apiClient apiClient
.get(link) .get(link)
.then(response => response.text()) .then((response) => response.text())
.then(JSON.parse) .then(JSON.parse)
.then(pluginCollection => pluginCollection._embedded.plugins) .then((pluginCollection) => pluginCollection._embedded.plugins)
.then(this.loadPlugins) .then(this.loadPlugins)
.then(this.props.callback); .then(this.props.callback);
}; };
loadPlugins = (plugins: Plugin[]) => { loadPlugins = (plugins: Plugin[]) => {
this.setState({ this.setState({
message: "loading plugins" message: "loading plugins",
}); });
const promises = []; const promises = [];
@@ -94,16 +96,19 @@ class PluginLoader extends React.Component<Props, State> {
loadPlugin = (plugin: Plugin) => { loadPlugin = (plugin: Plugin) => {
const promises = []; const promises = [];
for (const bundle of plugin.bundles) { for (const bundlePath of plugin.bundles) {
promises.push( promises.push(
loadBundle(bundle).catch(error => this.setState({ error, errorMessage: `loading ${plugin.name} failed` })) (isMainModuleBundle(bundlePath, plugin.name)
? loadBundle(bundlePath, plugin.name)
: loadBundle(bundlePath)
).catch((error) => this.setState({ error, errorMessage: `loading ${plugin.name} failed` }))
); );
} }
return Promise.all(promises); return Promise.all(promises);
}; };
render() { render() {
const { loaded } = this.props; const { loaded, children } = this.props;
const { message, error, errorMessage } = this.state; const { message, error, errorMessage } = this.state;
if (error) { if (error) {
@@ -130,11 +135,7 @@ class PluginLoader extends React.Component<Props, State> {
} }
if (loaded) { if (loaded) {
return ( return <ExtensionPoint name="main.wrapper">{children}</ExtensionPoint>;
<ExtensionPoint name="main.wrapper" wrapper={true}>
{this.props.children}
</ExtensionPoint>
);
} }
return <Loading message={message} />; return <Loading message={message} />;
} }

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC, useRef } from "react";
import { Redirect } from "react-router-dom"; import { Redirect } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRequiredIndexLink } from "@scm-manager/ui-api"; import { useRequiredIndexLink } from "@scm-manager/ui-api";
@@ -45,17 +45,7 @@ const CreateUser: FC = () => {
contentType: "application/vnd.scmm-user+json;v=2", contentType: "application/vnd.scmm-user+json;v=2",
} }
); );
const defaultValuesRef = useRef({
if (!!createdUser) {
return <Redirect to={`/user/${createdUser.name}`} />;
}
return (
<Page title={t("createUser.title")} subtitle={t("createUser.subtitle")} showContentOnError={true}>
<Form
onSubmit={submit}
translationPath={["users", "createUser.form"]}
defaultValues={{
name: "", name: "",
password: "", password: "",
passwordConfirmation: "", passwordConfirmation: "",
@@ -63,8 +53,15 @@ const CreateUser: FC = () => {
external: false, external: false,
displayName: "", displayName: "",
mail: "", mail: "",
}} });
>
if (!!createdUser) {
return <Redirect to={`/user/${createdUser.name}`} />;
}
return (
<Page title={t("createUser.title")} subtitle={t("createUser.subtitle")} showContentOnError={true}>
<Form onSubmit={submit} translationPath={["users", "createUser.form"]} defaultValues={defaultValuesRef.current}>
{({ watch }) => ( {({ watch }) => (
<> <>
<Form.Row> <Form.Row>