Fix UI plugin race conditions (#5523)

* useScript to return load state of scripts
* Wait for scripts to load before rendering

Also moves plugin code into plugins.tsx
This commit is contained in:
WithoutPants
2024-12-03 08:02:46 +11:00
committed by GitHub
parent 4be793d4b3
commit a0e09bbe5c
4 changed files with 184 additions and 134 deletions

View File

@@ -18,7 +18,6 @@ import locales, { registerCountry } from "src/locales";
import { import {
useConfiguration, useConfiguration,
useConfigureUI, useConfigureUI,
usePlugins,
useSystemStatus, useSystemStatus,
} from "src/core/StashService"; } from "src/core/StashService";
import flattenMessages from "./utils/flattenMessages"; import flattenMessages from "./utils/flattenMessages";
@@ -40,12 +39,9 @@ import { releaseNotes } from "./docs/en/ReleaseNotes";
import { getPlatformURL } from "./core/createClient"; import { getPlatformURL } from "./core/createClient";
import { lazyComponent } from "./utils/lazyComponent"; import { lazyComponent } from "./utils/lazyComponent";
import { isPlatformUniquelyRenderedByApple } from "./utils/apple"; import { isPlatformUniquelyRenderedByApple } from "./utils/apple";
import useScript, { useCSS } from "./hooks/useScript";
import { useMemoOnce } from "./hooks/state";
import Event from "./hooks/event"; import Event from "./hooks/event";
import { uniq } from "lodash-es";
import { PluginRoutes } from "./plugins"; import { PluginRoutes, PluginsLoader } from "./plugins";
// import plugin_api to run code // import plugin_api to run code
import "./pluginApi"; import "./pluginApi";
@@ -97,54 +93,6 @@ function languageMessageString(language: string) {
return language.replace(/-/, ""); return language.replace(/-/, "");
} }
type PluginList = NonNullable<Required<GQL.PluginsQuery["plugins"]>>;
// sort plugins by their dependencies
function sortPlugins(plugins: PluginList) {
type Node = { id: string; afters: string[] };
let nodes: Record<string, Node> = {};
let sorted: PluginList = [];
let visited: Record<string, boolean> = {};
plugins.forEach((v) => {
let from = v.id;
if (!nodes[from]) nodes[from] = { id: from, afters: [] };
v.requires?.forEach((to) => {
if (!nodes[to]) nodes[to] = { id: to, afters: [] };
if (!nodes[to].afters.includes(from)) nodes[to].afters.push(from);
});
});
function visit(idstr: string, ancestors: string[] = []) {
let node = nodes[idstr];
const { id } = node;
if (visited[idstr]) return;
ancestors.push(id);
visited[idstr] = true;
node.afters.forEach(function (afterID) {
if (ancestors.indexOf(afterID) >= 0)
throw new Error("closed chain : " + afterID + " is in " + id);
visit(afterID.toString(), ancestors.slice());
});
const plugin = plugins.find((v) => v.id === id);
if (plugin) {
sorted.unshift(plugin);
}
}
Object.keys(nodes).forEach((n) => {
visit(n);
});
return sorted;
}
const AppContainer: React.FC<React.PropsWithChildren<{}>> = PatchFunction( const AppContainer: React.FC<React.PropsWithChildren<{}>> = PatchFunction(
"App", "App",
(props: React.PropsWithChildren<{}>) => { (props: React.PropsWithChildren<{}>) => {
@@ -215,46 +163,6 @@ export const App: React.FC = () => {
setLocale(); setLocale();
}, [customMessages, language]); }, [customMessages, language]);
const {
data: plugins,
loading: pluginsLoading,
error: pluginsError,
} = usePlugins();
const sortedPlugins = useMemoOnce(() => {
return [
sortPlugins(plugins?.plugins ?? []),
!pluginsLoading && !pluginsError,
];
}, [plugins?.plugins, pluginsLoading, pluginsError]);
const pluginJavascripts = useMemoOnce(() => {
return [
uniq(
sortedPlugins
?.filter((plugin) => plugin.enabled && plugin.paths.javascript)
.map((plugin) => plugin.paths.javascript!)
.flat() ?? []
),
!!sortedPlugins && !pluginsLoading && !pluginsError,
];
}, [sortedPlugins, pluginsLoading, pluginsError]);
const pluginCSS = useMemoOnce(() => {
return [
uniq(
sortedPlugins
?.filter((plugin) => plugin.enabled && plugin.paths.css)
.map((plugin) => plugin.paths.css!)
.flat() ?? []
),
!!sortedPlugins && !pluginsLoading && !pluginsError,
];
}, [sortedPlugins, pluginsLoading, pluginsError]);
useScript(pluginJavascripts ?? [], !pluginsLoading && !pluginsError);
useCSS(pluginCSS ?? [], !pluginsLoading && !pluginsError);
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const setupMatch = useRouteMatch(["/setup", "/migrate"]); const setupMatch = useRouteMatch(["/setup", "/migrate"]);
@@ -365,43 +273,45 @@ export const App: React.FC = () => {
const titleProps = makeTitleProps(); const titleProps = makeTitleProps();
return ( return (
<AppContainer> <ErrorBoundary>
<ErrorBoundary> {messages ? (
{messages ? ( <IntlProvider
<IntlProvider locale={language}
locale={language} messages={messages}
messages={messages} formats={intlFormats}
formats={intlFormats} >
> <PluginsLoader>
<ConfigurationProvider <AppContainer>
configuration={config.data?.configuration} <ConfigurationProvider
loading={config.loading} configuration={config.data?.configuration}
> loading={config.loading}
{maybeRenderReleaseNotes()} >
<ToastProvider> {maybeRenderReleaseNotes()}
<ConnectionMonitor /> <ToastProvider>
<Suspense fallback={<LoadingIndicator />}> <ConnectionMonitor />
<LightboxProvider> <Suspense fallback={<LoadingIndicator />}>
<ManualProvider> <LightboxProvider>
<InteractiveProvider> <ManualProvider>
<Helmet {...titleProps} /> <InteractiveProvider>
{maybeRenderNavbar()} <Helmet {...titleProps} />
<div {maybeRenderNavbar()}
className={`main container-fluid ${ <div
appleRendering ? "apple" : "" className={`main container-fluid ${
}`} appleRendering ? "apple" : ""
> }`}
{renderContent()} >
</div> {renderContent()}
</InteractiveProvider> </div>
</ManualProvider> </InteractiveProvider>
</LightboxProvider> </ManualProvider>
</Suspense> </LightboxProvider>
</ToastProvider> </Suspense>
</ConfigurationProvider> </ToastProvider>
</IntlProvider> </ConfigurationProvider>
) : null} </AppContainer>
</ErrorBoundary> </PluginsLoader>
</AppContainer> </IntlProvider>
) : null}
</ErrorBoundary>
); );
}; };

View File

@@ -1,6 +1,9 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
const useScript = (urls: string | string[], condition: boolean = true) => {
// array of booleans to track the loading state of each script
const [loadStates, setLoadStates] = useState<boolean[]>();
const useScript = (urls: string | string[], condition?: boolean) => {
const urlArray = useMemo(() => { const urlArray = useMemo(() => {
if (!Array.isArray(urls)) { if (!Array.isArray(urls)) {
return [urls]; return [urls];
@@ -10,12 +13,25 @@ const useScript = (urls: string | string[], condition?: boolean) => {
}, [urls]); }, [urls]);
useEffect(() => { useEffect(() => {
if (condition) {
setLoadStates(urlArray.map(() => false));
}
const scripts = urlArray.map((url) => { const scripts = urlArray.map((url) => {
const script = document.createElement("script"); const script = document.createElement("script");
script.src = url; script.src = url;
script.async = false; script.async = false;
script.defer = true; script.defer = true;
function onLoad() {
setLoadStates((prev) =>
prev!.map((state, i) => (i === urlArray.indexOf(url) ? true : state))
);
}
script.addEventListener("load", onLoad);
script.addEventListener("error", onLoad); // handle error as well
return script; return script;
}); });
@@ -33,6 +49,12 @@ const useScript = (urls: string | string[], condition?: boolean) => {
} }
}; };
}, [urlArray, condition]); }, [urlArray, condition]);
return (
condition &&
loadStates &&
(loadStates.length === 0 || loadStates.every((state) => state))
);
}; };
export const useCSS = (urls: string | string[], condition?: boolean) => { export const useCSS = (urls: string | string[], condition?: boolean) => {

View File

@@ -1121,7 +1121,8 @@
"last_played_at": "Last Played At", "last_played_at": "Last Played At",
"library": "Library", "library": "Library",
"loading": { "loading": {
"generic": "Loading…" "generic": "Loading…",
"plugins": "Loading plugins…"
}, },
"marker_count": "Marker Count", "marker_count": "Marker Count",
"markers": "Markers", "markers": "Markers",

View File

@@ -1,5 +1,122 @@
import React from "react"; import React from "react";
import { PatchFunction } from "./patch"; import { PatchFunction } from "./patch";
import { usePlugins } from "./core/StashService";
import { useMemoOnce } from "./hooks/state";
import { uniq } from "lodash-es";
import useScript, { useCSS } from "./hooks/useScript";
import { PluginsQuery } from "./core/generated-graphql";
import { LoadingIndicator } from "./components/Shared/LoadingIndicator";
import { FormattedMessage } from "react-intl";
type PluginList = NonNullable<Required<PluginsQuery["plugins"]>>;
// sort plugins by their dependencies
function sortPlugins(plugins: PluginList) {
type Node = { id: string; afters: string[] };
let nodes: Record<string, Node> = {};
let sorted: PluginList = [];
let visited: Record<string, boolean> = {};
plugins.forEach((v) => {
let from = v.id;
if (!nodes[from]) nodes[from] = { id: from, afters: [] };
v.requires?.forEach((to) => {
if (!nodes[to]) nodes[to] = { id: to, afters: [] };
if (!nodes[to].afters.includes(from)) nodes[to].afters.push(from);
});
});
function visit(idstr: string, ancestors: string[] = []) {
let node = nodes[idstr];
const { id } = node;
if (visited[idstr]) return;
ancestors.push(id);
visited[idstr] = true;
node.afters.forEach(function (afterID) {
if (ancestors.indexOf(afterID) >= 0)
throw new Error("closed chain : " + afterID + " is in " + id);
visit(afterID.toString(), ancestors.slice());
});
const plugin = plugins.find((v) => v.id === id);
if (plugin) {
sorted.unshift(plugin);
}
}
Object.keys(nodes).forEach((n) => {
visit(n);
});
return sorted;
}
// load all plugins and their dependencies
// returns true when all plugins are loaded, regardess of success or failure
function useLoadPlugins() {
const {
data: plugins,
loading: pluginsLoading,
error: pluginsError,
} = usePlugins();
const sortedPlugins = useMemoOnce(() => {
return [
sortPlugins(plugins?.plugins ?? []),
!pluginsLoading && !pluginsError,
];
}, [plugins?.plugins, pluginsLoading, pluginsError]);
const pluginJavascripts = useMemoOnce(() => {
return [
uniq(
sortedPlugins
?.filter((plugin) => plugin.enabled && plugin.paths.javascript)
.map((plugin) => plugin.paths.javascript!)
.flat() ?? []
),
!!sortedPlugins && !pluginsLoading && !pluginsError,
];
}, [sortedPlugins, pluginsLoading, pluginsError]);
const pluginCSS = useMemoOnce(() => {
return [
uniq(
sortedPlugins
?.filter((plugin) => plugin.enabled && plugin.paths.css)
.map((plugin) => plugin.paths.css!)
.flat() ?? []
),
!!sortedPlugins && !pluginsLoading && !pluginsError,
];
}, [sortedPlugins, pluginsLoading, pluginsError]);
const pluginJavascriptLoaded = useScript(
pluginJavascripts ?? [],
!!pluginJavascripts && !pluginsLoading && !pluginsError
);
useCSS(pluginCSS ?? [], !pluginsLoading && !pluginsError);
return !pluginsLoading && !!pluginJavascripts && pluginJavascriptLoaded;
}
export const PluginsLoader: React.FC<React.PropsWithChildren<{}>> = ({
children,
}) => {
const loaded = useLoadPlugins();
if (!loaded)
return (
<LoadingIndicator message={<FormattedMessage id="loading.plugins" />} />
);
return <>{children}</>;
};
export const PluginRoutes: React.FC<React.PropsWithChildren<{}>> = export const PluginRoutes: React.FC<React.PropsWithChildren<{}>> =
PatchFunction("PluginRoutes", (props: React.PropsWithChildren<{}>) => { PatchFunction("PluginRoutes", (props: React.PropsWithChildren<{}>) => {