diff --git a/docs/docs/plugins/creator.md b/docs/docs/plugins/creator.md
index 39bee77aa8c3..74d4acd2b0df 100644
--- a/docs/docs/plugins/creator.md
+++ b/docs/docs/plugins/creator.md
@@ -214,10 +214,10 @@ The frontend code for your plugin is located in the `frontend/src` directory. Yo
Refer to the `./frontend/src/Panel.tsx` file as a starting point. This is where the custom panel for the part detail page is implemented. You can modify this file to change the content and behavior of the panel.
-While the `npm dev` server is running, any changes you make to the frontend code will be automatically reloaded allowing for rapid development and testing of your plugin's frontend features. This avoids the need to rebuild the frontend code every time you make a change.
+While the `npm dev` server is running, any changes to the frontend are reflected in the browser using React Fast Refresh, allowing for rapid development without rebuilding the frontend with every change.
!!! info "Page Reload"
- Due to the way the InvenTree frontend is structured, you will need to manually refresh the page in your browser to see changes to the frontend code. The development server will automatically reload the frontend code, but the InvenTree server needs to be aware of the changes.
+ All exports in plugin modules that export React components must start with a capital letter. Otherwise, React Fast Refresh will fall back to a full page reload instead of performing a component-level update. Additionally, any render functions referenced from Python must also be capitalized.
## Build Plugin
diff --git a/docs/docs/plugins/walkthrough.md b/docs/docs/plugins/walkthrough.md
index af2d75885ee2..5fe885e23313 100644
--- a/docs/docs/plugins/walkthrough.md
+++ b/docs/docs/plugins/walkthrough.md
@@ -119,7 +119,7 @@ function AttachmentCarouselPanel({context}: {context: InvenTreePluginContext;})
}
// This is the function which is called by InvenTree to render the actual panel component
-export function renderAttachmentCarouselPanel(context: InvenTreePluginContext) {
+export function RenderAttachmentCarouselPanel(context: InvenTreePluginContext) {
checkPluginVersion(context);
return ;
}
@@ -300,7 +300,7 @@ Back to the walkthrough, open `core.py` in the `attachment_carousel` folder and
'title': 'Attachment Carousel',
'description': 'Custom panel description',
'icon': 'ti:carousel-horizontal:outline',
- 'source': self.plugin_static_file('Panel.js:renderAttachmentCarouselPanel'),
+ 'source': self.plugin_static_file('Panel.js:RenderAttachmentCarouselPanel'),
'context': {
# Provide additional context data to the panel
'settings': self.get_settings_dict(),
@@ -385,7 +385,7 @@ panels.append({
'description': 'Custom panel description',
- 'icon': 'ti:mood-smile:outline',
+ 'icon': 'ti:carousel-horizontal:outline',
- 'source': self.plugin_static_file('Panel.js:renderAttachmentCarouselPanel'),
+ 'source': self.plugin_static_file('Panel.js:RenderAttachmentCarouselPanel'),
'context': {
# Provide additional context data to the panel
'settings': self.get_settings_dict(),
@@ -519,7 +519,7 @@ panels.append({
'title': 'Attachment Carousel',
'description': 'Custom panel description',
'icon': 'ti:carousel-horizontal:outline',
- 'source': self.plugin_static_file('Panel.js:renderAttachmentCarouselPanel'),
+ 'source': self.plugin_static_file('Panel.js:RenderAttachmentCarouselPanel'),
'context': {
# Provide additional context data to the panel
'settings': self.get_settings_dict(),
diff --git a/src/frontend/CHANGELOG.md b/src/frontend/CHANGELOG.md
index 158082281cff..c9c55f7a1661 100644
--- a/src/frontend/CHANGELOG.md
+++ b/src/frontend/CHANGELOG.md
@@ -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:
diff --git a/src/frontend/src/components/plugins/RemoteComponent.tsx b/src/frontend/src/components/plugins/RemoteComponent.tsx
index 671dc7c5a0af..ad01ac30d7bc 100644
--- a/src/frontend/src/components/plugins/RemoteComponent.tsx
+++ b/src/frontend/src/components/plugins/RemoteComponent.tsx
@@ -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(null);
- const rootElement = useRef(null);
+ const containerRef = useRef(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(
- undefined
+ const content = componentFn ? (
+ componentFn(pluginContext)
+ ) : (
+
);
- 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(
-
-
- {func(ctx)}
-
-
- );
- }
-
- 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 (
-
-
- {renderingError && (
- }
- >
-
- {t`Error occurred while loading plugin content`}: {renderingError}
-
-
- )}
- {componentRef && }
-
-
+
+ {errorMsg && (
+ }
+ >
+ {errorMsg}
+
+ )}
+
+ {content}
+
+
);
}
diff --git a/src/frontend/src/hooks/UseRemotePlugin.tsx b/src/frontend/src/hooks/UseRemotePlugin.tsx
new file mode 100644
index 000000000000..8d0460cfe448
--- /dev/null
+++ b/src/frontend/src/hooks/UseRemotePlugin.tsx
@@ -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;
+};
+
+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();
+ 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 | null>(null);
+ const [reloadVersion, setReloadVersion] = useState(0);
+ const [errorMsg, setErrorMsg] = useState(null);
+
+ const reloadContent = useCallback(() => setReloadVersion((v) => v + 1), []);
+
+ const hmrSetModule = useCallback(
+ (newRemoteModule: Record | 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
+ };
+}
diff --git a/src/frontend/tests/pui_plugins.spec.ts b/src/frontend/tests/pui_plugins.spec.ts
index a4bc753303d6..c6835418d182 100644
--- a/src/frontend/tests/pui_plugins.spec.ts
+++ b/src/frontend/tests/pui_plugins.spec.ts
@@ -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