mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-12 03:28:37 +00:00
Add HMR and React Fast Refresh support (#12060)
* Add HMR and React Fast Refresh support * Run pre-commit hooks * Fix 'hmrSetModule' module loading The incoming module needs to include the URL from which it was loaded, so that it's possible to enforce only loading modules imported from the same pathname as the current module. * Add error handling and improvements - Add error handling to `useRemotePlugin` and simplify `RemoteComponent` - Improve HMR to use a registry instead of a single global callback. This should now handle two legacy plugin entry points being used at the same time via RemoteComponent. * Update docs * Update CHANGELOG * Remove use of LanguageContext from RemoteComponent LanguageContext should not be necessary here, as it's provided in ThemeContext, which is used in InvenTree's frontend entry. * Fix incorrect import.meta.hot access * Update Playwright test to match UI text changes --------- Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
@@ -27,6 +27,10 @@ Exposes sub-components related to DetailDrawer rendering:
|
||||
- `DetailDrawerComponent`
|
||||
- `useLocalLibState`
|
||||
|
||||
#### Plugin System
|
||||
|
||||
Enable React Fast Refresh support for plugin frontend development. Plugin modules exporting React components must start with a capital letter; otherwise, a full page reload occurs instead of a component-level update.
|
||||
|
||||
### 0.11.3 - April 2026
|
||||
|
||||
Exposes additional type definitions related to rendering drawers from tables:
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert, MantineProvider, Stack, Text } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Stack, Text } from '@mantine/core';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { Boundary } from '@lib/components/Boundary';
|
||||
import { identifierString } from '@lib/functions/Conversion';
|
||||
import type { InvenTreePluginContext } from '@lib/types/Plugins';
|
||||
import { type Root, createRoot } from 'react-dom/client';
|
||||
import { api, queryClient } from '../../App';
|
||||
import { ApiProvider } from '../../contexts/ApiContext';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { findExternalPluginFunction } from './PluginSource';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { useRemotePlugin } from '../../hooks/UseRemotePlugin';
|
||||
|
||||
/**
|
||||
* A remote component which can be used to display plugin content.
|
||||
@@ -31,125 +26,39 @@ export default function RemoteComponent({
|
||||
defaultFunctionName: string;
|
||||
context: InvenTreePluginContext;
|
||||
}>) {
|
||||
const componentRef = useRef<HTMLDivElement | null>(null);
|
||||
const rootElement = useRef<Root | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (componentRef.current && rootElement.current === null) {
|
||||
rootElement.current = createRoot(componentRef.current);
|
||||
}
|
||||
}, [rootElement]);
|
||||
const { componentFn, errorMsg, exportName, pluginContext, remountKey } =
|
||||
useRemotePlugin({
|
||||
context,
|
||||
source,
|
||||
defaultFunctionName,
|
||||
containerRef
|
||||
});
|
||||
|
||||
const [renderingError, setRenderingError] = useState<string | undefined>(
|
||||
undefined
|
||||
const content = componentFn ? (
|
||||
componentFn(pluginContext)
|
||||
) : (
|
||||
<div ref={containerRef} />
|
||||
);
|
||||
|
||||
const func: string = useMemo(() => {
|
||||
// Attempt to extract the function name from the source
|
||||
const { getHost } = useLocalState.getState();
|
||||
const url = new URL(source, getHost());
|
||||
|
||||
if (url.pathname.includes(':')) {
|
||||
const parts = url.pathname.split(':');
|
||||
return parts[1] || defaultFunctionName; // Use the second part as the function name, or fallback to default
|
||||
} else {
|
||||
return defaultFunctionName;
|
||||
}
|
||||
}, [source, defaultFunctionName]);
|
||||
|
||||
const reloadPluginContent = useCallback(() => {
|
||||
if (!rootElement.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx: InvenTreePluginContext = {
|
||||
...context,
|
||||
reloadContent: reloadPluginContent
|
||||
};
|
||||
|
||||
if (source && defaultFunctionName) {
|
||||
findExternalPluginFunction(source, func)
|
||||
.then((func) => {
|
||||
if (!!func) {
|
||||
try {
|
||||
if (func.length > 1) {
|
||||
// Support "legacy" plugin functions which call createRoot() internally
|
||||
// Ref: https://github.com/inventree/InvenTree/pull/9439/
|
||||
func(componentRef.current, ctx);
|
||||
} else {
|
||||
// Render the plugin component into the target element
|
||||
// Note that we have to provide the right context(s) to the component
|
||||
// This approach ensures that the component is rendered in the correct context tree
|
||||
rootElement.current?.render(
|
||||
<ApiProvider client={queryClient} api={api}>
|
||||
<MantineProvider
|
||||
theme={ctx.theme}
|
||||
defaultColorScheme={ctx.colorScheme}
|
||||
>
|
||||
<LanguageContext>{func(ctx)}</LanguageContext>
|
||||
</MantineProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
setRenderingError('');
|
||||
} catch (error) {
|
||||
setRenderingError(`${error}`);
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
setRenderingError(`${source} / ${func}`);
|
||||
}
|
||||
})
|
||||
.catch((_error) => {
|
||||
console.error(
|
||||
`ERR: Failed to load remote plugin function: ${source} /${func}`
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setRenderingError(
|
||||
`${t`Invalid source or function name`} - ${source} /${func}`
|
||||
);
|
||||
}
|
||||
}, [
|
||||
componentRef.current,
|
||||
rootElement.current,
|
||||
source,
|
||||
defaultFunctionName,
|
||||
context
|
||||
]);
|
||||
|
||||
// Reload the plugin content dynamically
|
||||
useEffect(() => {
|
||||
reloadPluginContent();
|
||||
}, [
|
||||
func,
|
||||
rootElement.current,
|
||||
context.id,
|
||||
context.model,
|
||||
context.instance,
|
||||
context.user,
|
||||
context.colorScheme,
|
||||
context.locale,
|
||||
context.context
|
||||
]);
|
||||
|
||||
return (
|
||||
<Boundary label={identifierString(`RemoteComponent-${func}`)}>
|
||||
<Stack gap='xs'>
|
||||
{renderingError && (
|
||||
<Alert
|
||||
color='red'
|
||||
title={t`Error Loading Content`}
|
||||
icon={<IconExclamationCircle />}
|
||||
>
|
||||
<Text>
|
||||
{t`Error occurred while loading plugin content`}: {renderingError}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{componentRef && <div ref={componentRef as any} />}
|
||||
</Stack>
|
||||
</Boundary>
|
||||
<Stack>
|
||||
{errorMsg && (
|
||||
<Alert
|
||||
color='red'
|
||||
title={t`Error Loading Plugin Content`}
|
||||
icon={<IconExclamationCircle />}
|
||||
>
|
||||
<Text>{errorMsg}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Boundary
|
||||
key={remountKey}
|
||||
label={identifierString(`RemoteComponent-${exportName}`)}
|
||||
>
|
||||
{content}
|
||||
</Boundary>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { InvenTreePluginContext } from '@lib/types/Plugins';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
|
||||
type LegacyPluginEntryFn = (
|
||||
container: HTMLDivElement,
|
||||
ctx: InvenTreePluginContext
|
||||
) => void;
|
||||
|
||||
type PluginEntryFn = (ctx: InvenTreePluginContext) => ReactElement;
|
||||
|
||||
type UseRemotePluginOptions = {
|
||||
context: InvenTreePluginContext;
|
||||
source: string;
|
||||
defaultFunctionName: string;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
type UsePluginSourceOptions = {
|
||||
source: string;
|
||||
defaultFunctionName?: string;
|
||||
};
|
||||
|
||||
type UseRemotePluginReturn = {
|
||||
componentFn: PluginEntryFn | null;
|
||||
errorMsg: string | null;
|
||||
exportName: string;
|
||||
pluginContext: InvenTreePluginContext;
|
||||
remountKey: number;
|
||||
};
|
||||
|
||||
function usePluginSource({
|
||||
source,
|
||||
defaultFunctionName
|
||||
}: UsePluginSourceOptions) {
|
||||
const { getHost } = useLocalState.getState();
|
||||
|
||||
const { moduleUrl, exportName } = useMemo(() => {
|
||||
const url = new URL(source, getHost());
|
||||
const parts = url.pathname.split(':');
|
||||
|
||||
return {
|
||||
exportName: parts[1] || defaultFunctionName || 'default',
|
||||
moduleUrl: url.origin + parts[0]
|
||||
};
|
||||
}, [source, defaultFunctionName, getHost]);
|
||||
|
||||
return { moduleUrl, exportName };
|
||||
}
|
||||
|
||||
function getHmrCallbacks(url: string) {
|
||||
const w = window as any;
|
||||
w.__plugin_hmr_callbacks ??= {};
|
||||
w.__plugin_hmr_callbacks[url] ??= new Set<Function>();
|
||||
return w.__plugin_hmr_callbacks[url];
|
||||
}
|
||||
|
||||
const hasHmr = import.meta.hot !== undefined;
|
||||
|
||||
export function useRemotePlugin({
|
||||
context,
|
||||
source,
|
||||
defaultFunctionName,
|
||||
containerRef
|
||||
}: UseRemotePluginOptions): UseRemotePluginReturn {
|
||||
const { moduleUrl, exportName } = usePluginSource({
|
||||
source,
|
||||
defaultFunctionName
|
||||
});
|
||||
|
||||
const [remoteModule, setRemoteModule] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null);
|
||||
const [reloadVersion, setReloadVersion] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const reloadContent = useCallback(() => setReloadVersion((v) => v + 1), []);
|
||||
|
||||
const hmrSetModule = useCallback(
|
||||
(newRemoteModule: Record<string, unknown> | null) => {
|
||||
if (!hasHmr) return;
|
||||
setRemoteModule(newRemoteModule);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
setErrorMsg(null);
|
||||
|
||||
const loadModule = async () => {
|
||||
try {
|
||||
const mod = await import(/* @vite-ignore */ moduleUrl);
|
||||
if (!cancelled) setRemoteModule(mod);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error(`ERR: Failed to load module: ${moduleUrl}:\n${err}`);
|
||||
setErrorMsg(t`Failed to load module: ${moduleUrl}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadModule();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [moduleUrl]);
|
||||
|
||||
const [legacyRenderFn, componentFn, error] = useMemo(() => {
|
||||
if (!remoteModule) return [null, null, null];
|
||||
|
||||
let err: string | null = null;
|
||||
const func = remoteModule[exportName];
|
||||
|
||||
if (typeof func === 'function') {
|
||||
if (func.length === 2) {
|
||||
return [func as LegacyPluginEntryFn, null, null];
|
||||
} else if (func.length === 1) {
|
||||
return [null, func as PluginEntryFn, null];
|
||||
} else {
|
||||
err = `Entrypoint ${exportName} in ${moduleUrl} must accept 1-2 arguments`;
|
||||
}
|
||||
} else if (func !== undefined) {
|
||||
err = t`Export ${exportName} in ${moduleUrl} is not a function (found type ${typeof func}).`;
|
||||
} else {
|
||||
err = t`Plugin entrypoint ${exportName} does not exist in ${moduleUrl}.`;
|
||||
}
|
||||
|
||||
return [null, null, err];
|
||||
}, [remoteModule, exportName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (legacyRenderFn && containerRef.current) {
|
||||
containerRef.current.innerHTML = '';
|
||||
legacyRenderFn(containerRef.current, context);
|
||||
|
||||
if (hasHmr) getHmrCallbacks(moduleUrl)?.add(hmrSetModule);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hasHmr) getHmrCallbacks(moduleUrl)?.delete(hmrSetModule);
|
||||
};
|
||||
}, [moduleUrl, legacyRenderFn, context, hmrSetModule]);
|
||||
|
||||
return {
|
||||
componentFn: componentFn,
|
||||
errorMsg: error ?? errorMsg,
|
||||
exportName: exportName,
|
||||
pluginContext: { ...context, reloadContent: reloadContent },
|
||||
remountKey: reloadVersion
|
||||
};
|
||||
}
|
||||
@@ -174,7 +174,7 @@ test('Plugins - Panels', async ({ browser }) => {
|
||||
|
||||
// Check out each of the plugin panels
|
||||
await loadTab(page, 'Broken Panel');
|
||||
await page.getByText('Error occurred while loading plugin content').waitFor();
|
||||
await page.getByText('Error Loading Plugin Content').waitFor();
|
||||
await loadTab(page, 'Dynamic Panel');
|
||||
await page.getByText('Instance ID: 69');
|
||||
await page
|
||||
|
||||
Reference in New Issue
Block a user