From 2ad9132cae17a2921af885711055858ce10f661e Mon Sep 17 00:00:00 2001 From: xhivo Date: Mon, 1 Jun 2026 01:39:54 +0200 Subject: [PATCH 1/9] Add HMR and React Fast Refresh support --- .../components/plugins/RemoteComponent.tsx | 149 +++--------------- src/frontend/src/hooks/UseRemotePlugin.tsx | 101 ++++++++++++ 2 files changed, 121 insertions(+), 129 deletions(-) create mode 100644 src/frontend/src/hooks/UseRemotePlugin.tsx diff --git a/src/frontend/src/components/plugins/RemoteComponent.tsx b/src/frontend/src/components/plugins/RemoteComponent.tsx index 671dc7c5a0af..225a80d06286 100644 --- a/src/frontend/src/components/plugins/RemoteComponent.tsx +++ b/src/frontend/src/components/plugins/RemoteComponent.tsx @@ -1,17 +1,11 @@ -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 { MantineProvider } 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 { useRemotePlugin } from '../../hooks/UseRemotePlugin'; /** * A remote component which can be used to display plugin content. @@ -31,125 +25,22 @@ export default function RemoteComponent({ defaultFunctionName: string; context: InvenTreePluginContext; }>) { - const componentRef = useRef(null); - const rootElement = useRef(null); - - useEffect(() => { - if (componentRef.current && rootElement.current === null) { - rootElement.current = createRoot(componentRef.current); - } - }, [rootElement]); - - const [renderingError, setRenderingError] = useState( - undefined - ); - - 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 &&
} - - + const containerRef = useRef(null); + + const componentFn = useRemotePlugin({context, source, defaultFunctionName, containerRef}); + + return componentFn ? ( + + + + {componentFn(context)} + + + + ) : ( +
); } diff --git a/src/frontend/src/hooks/UseRemotePlugin.tsx b/src/frontend/src/hooks/UseRemotePlugin.tsx new file mode 100644 index 000000000000..bd74d1ef8629 --- /dev/null +++ b/src/frontend/src/hooks/UseRemotePlugin.tsx @@ -0,0 +1,101 @@ +import type { InvenTreePluginContext } from '@lib/types/Plugins'; +import { useEffect, useState, useCallback, useMemo } from "react"; +import { useLocalState } from '../states/LocalState'; + +type LegacyRemoteRenderFnType = (container: HTMLDivElement, ctx: InvenTreePluginContext) => void; +type RemoteComponentType = (ctx: InvenTreePluginContext) => React.ReactElement; + +interface UseRemotePluginProps { + context: InvenTreePluginContext; + source: string; + defaultFunctionName: string; + containerRef: React.RefObject; +} + +interface UsePluginSourceProps { + source: string; + defaultFunctionName?: string; +} + +function usePluginSource({source, defaultFunctionName}: UsePluginSourceProps) { + 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 }; +} + +export function useRemotePlugin( + {context, source, defaultFunctionName, containerRef}: UseRemotePluginProps +): RemoteComponentType | null { + const { moduleUrl, exportName } = usePluginSource({source, defaultFunctionName}); + const [mod, setModule] = useState | null>(null); + + const hmrSetModule = useCallback((mod: Record) => { + setModule(mod); + }, []); + + useEffect(() => { + let cancelled = false; + + async function load() { + const _mod = await import(/* @vite-ignore */moduleUrl); + + if (!cancelled) { + setModule(_mod); + } + } + + load(); + + return () => { + cancelled = true; + }; + }, [moduleUrl]); + + const legacyRenderFn = useMemo(() => { + if (!mod) return null; + + const func = mod[exportName]; + + if (typeof func === 'function' && func.length === 2) { + return (func as LegacyRemoteRenderFnType); + } + + return null; + }, [mod, exportName]); + + const componentFn = useMemo(() => { + if (!mod) return null; + + const func = mod[exportName]; + + if (typeof func === 'function' && func.length === 1) { + return (func as RemoteComponentType); + } + + return null; + }, [mod, exportName]); + + useEffect(() => { + if (legacyRenderFn && containerRef.current) { + legacyRenderFn(containerRef.current, context); + (window as any).__plugin_hmr_reload = hmrSetModule; + } + + return () => { + delete (window as any).__plugin_hmr_reload; + } + }, [legacyRenderFn, context, hmrSetModule]); + + return componentFn; +} From 1476ff4235c7c0fa72302a32242c4649dba62cdb Mon Sep 17 00:00:00 2001 From: xhivo Date: Mon, 1 Jun 2026 14:03:50 +0200 Subject: [PATCH 2/9] Run pre-commit hooks --- .../components/plugins/RemoteComponent.tsx | 11 ++++-- src/frontend/src/hooks/UseRemotePlugin.tsx | 39 ++++++++++++------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/frontend/src/components/plugins/RemoteComponent.tsx b/src/frontend/src/components/plugins/RemoteComponent.tsx index 225a80d06286..d75781a6fce1 100644 --- a/src/frontend/src/components/plugins/RemoteComponent.tsx +++ b/src/frontend/src/components/plugins/RemoteComponent.tsx @@ -27,7 +27,12 @@ export default function RemoteComponent({ }>) { const containerRef = useRef(null); - const componentFn = useRemotePlugin({context, source, defaultFunctionName, containerRef}); + const componentFn = useRemotePlugin({ + context, + source, + defaultFunctionName, + containerRef + }); return componentFn ? ( @@ -35,9 +40,7 @@ export default function RemoteComponent({ theme={context.theme} defaultColorScheme={context.colorScheme} > - - {componentFn(context)} - + {componentFn(context)} ) : ( diff --git a/src/frontend/src/hooks/UseRemotePlugin.tsx b/src/frontend/src/hooks/UseRemotePlugin.tsx index bd74d1ef8629..c307536e6585 100644 --- a/src/frontend/src/hooks/UseRemotePlugin.tsx +++ b/src/frontend/src/hooks/UseRemotePlugin.tsx @@ -1,8 +1,11 @@ import type { InvenTreePluginContext } from '@lib/types/Plugins'; -import { useEffect, useState, useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocalState } from '../states/LocalState'; -type LegacyRemoteRenderFnType = (container: HTMLDivElement, ctx: InvenTreePluginContext) => void; +type LegacyRemoteRenderFnType = ( + container: HTMLDivElement, + ctx: InvenTreePluginContext +) => void; type RemoteComponentType = (ctx: InvenTreePluginContext) => React.ReactElement; interface UseRemotePluginProps { @@ -17,27 +20,35 @@ interface UsePluginSourceProps { defaultFunctionName?: string; } -function usePluginSource({source, defaultFunctionName}: UsePluginSourceProps) { +function usePluginSource({ + source, + defaultFunctionName +}: UsePluginSourceProps) { const { getHost } = useLocalState.getState(); const { moduleUrl, exportName } = useMemo(() => { const url = new URL(source, getHost()); - const parts = url.pathname.split(":"); + const parts = url.pathname.split(':'); return { exportName: parts[1] || defaultFunctionName || 'default', - moduleUrl: url.origin + parts[0], + moduleUrl: url.origin + parts[0] }; - }, [source, defaultFunctionName, getHost]); return { moduleUrl, exportName }; } -export function useRemotePlugin( - {context, source, defaultFunctionName, containerRef}: UseRemotePluginProps -): RemoteComponentType | null { - const { moduleUrl, exportName } = usePluginSource({source, defaultFunctionName}); +export function useRemotePlugin({ + context, + source, + defaultFunctionName, + containerRef +}: UseRemotePluginProps): RemoteComponentType | null { + const { moduleUrl, exportName } = usePluginSource({ + source, + defaultFunctionName + }); const [mod, setModule] = useState | null>(null); const hmrSetModule = useCallback((mod: Record) => { @@ -48,7 +59,7 @@ export function useRemotePlugin( let cancelled = false; async function load() { - const _mod = await import(/* @vite-ignore */moduleUrl); + const _mod = await import(/* @vite-ignore */ moduleUrl); if (!cancelled) { setModule(_mod); @@ -68,7 +79,7 @@ export function useRemotePlugin( const func = mod[exportName]; if (typeof func === 'function' && func.length === 2) { - return (func as LegacyRemoteRenderFnType); + return func as LegacyRemoteRenderFnType; } return null; @@ -80,7 +91,7 @@ export function useRemotePlugin( const func = mod[exportName]; if (typeof func === 'function' && func.length === 1) { - return (func as RemoteComponentType); + return func as RemoteComponentType; } return null; @@ -94,7 +105,7 @@ export function useRemotePlugin( return () => { delete (window as any).__plugin_hmr_reload; - } + }; }, [legacyRenderFn, context, hmrSetModule]); return componentFn; From 1d88e28e150e162182318f758abae82489e79cd1 Mon Sep 17 00:00:00 2001 From: xhivo Date: Mon, 1 Jun 2026 17:33:45 +0200 Subject: [PATCH 3/9] 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. --- src/frontend/src/hooks/UseRemotePlugin.tsx | 45 +++++++++++++++------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/src/frontend/src/hooks/UseRemotePlugin.tsx b/src/frontend/src/hooks/UseRemotePlugin.tsx index c307536e6585..64cbd64be909 100644 --- a/src/frontend/src/hooks/UseRemotePlugin.tsx +++ b/src/frontend/src/hooks/UseRemotePlugin.tsx @@ -2,11 +2,12 @@ import type { InvenTreePluginContext } from '@lib/types/Plugins'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocalState } from '../states/LocalState'; -type LegacyRemoteRenderFnType = ( +type LegacyRemoteRenderFn = ( container: HTMLDivElement, ctx: InvenTreePluginContext ) => void; -type RemoteComponentType = (ctx: InvenTreePluginContext) => React.ReactElement; + +type RemoteComponent = (ctx: InvenTreePluginContext) => React.ReactElement; interface UseRemotePluginProps { context: InvenTreePluginContext; @@ -20,6 +21,11 @@ interface UsePluginSourceProps { defaultFunctionName?: string; } +type RemoteModule = { + url: string; + mod: Record; +}; + function usePluginSource({ source, defaultFunctionName @@ -44,15 +50,22 @@ export function useRemotePlugin({ source, defaultFunctionName, containerRef -}: UseRemotePluginProps): RemoteComponentType | null { +}: UseRemotePluginProps): RemoteComponent | null { const { moduleUrl, exportName } = usePluginSource({ source, defaultFunctionName }); - const [mod, setModule] = useState | null>(null); - - const hmrSetModule = useCallback((mod: Record) => { - setModule(mod); + const [remoteModule, setRemoteModule] = useState(null); + + const hmrSetModule = useCallback((newRemoteModule: RemoteModule) => { + setRemoteModule((prevRemoteModule) => { + const prevUrl = new URL(prevRemoteModule?.url ?? ''); + const newUrl = new URL(newRemoteModule.url); + if (prevUrl.pathname === newUrl.pathname) { + return newRemoteModule; + } + return prevRemoteModule; + }); }, []); useEffect(() => { @@ -62,7 +75,7 @@ export function useRemotePlugin({ const _mod = await import(/* @vite-ignore */ moduleUrl); if (!cancelled) { - setModule(_mod); + setRemoteModule({ mod: _mod, url: moduleUrl }); } } @@ -74,28 +87,32 @@ export function useRemotePlugin({ }, [moduleUrl]); const legacyRenderFn = useMemo(() => { - if (!mod) return null; + if (!remoteModule) return null; + + const mod = remoteModule.mod; const func = mod[exportName]; if (typeof func === 'function' && func.length === 2) { - return func as LegacyRemoteRenderFnType; + return func as LegacyRemoteRenderFn; } return null; - }, [mod, exportName]); + }, [remoteModule, exportName]); const componentFn = useMemo(() => { - if (!mod) return null; + if (!remoteModule) return null; + + const mod = remoteModule.mod; const func = mod[exportName]; if (typeof func === 'function' && func.length === 1) { - return func as RemoteComponentType; + return func as RemoteComponent; } return null; - }, [mod, exportName]); + }, [remoteModule, exportName]); useEffect(() => { if (legacyRenderFn && containerRef.current) { From fd42479fdbd95a8e81b3c0144385560c8ad9a34b Mon Sep 17 00:00:00 2001 From: xhivo Date: Tue, 2 Jun 2026 21:35:13 +0200 Subject: [PATCH 4/9] 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. --- .../components/plugins/RemoteComponent.tsx | 46 +++--- src/frontend/src/hooks/UseRemotePlugin.tsx | 142 +++++++++++------- 2 files changed, 113 insertions(+), 75 deletions(-) diff --git a/src/frontend/src/components/plugins/RemoteComponent.tsx b/src/frontend/src/components/plugins/RemoteComponent.tsx index d75781a6fce1..5e9410d62538 100644 --- a/src/frontend/src/components/plugins/RemoteComponent.tsx +++ b/src/frontend/src/components/plugins/RemoteComponent.tsx @@ -1,9 +1,11 @@ -import { MantineProvider } from '@mantine/core'; +import { t } from '@lingui/core/macro'; +import { Alert, 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 { api, queryClient } from '../../App'; -import { ApiProvider } from '../../contexts/ApiContext'; +import { IconExclamationCircle } from '@tabler/icons-react'; import { LanguageContext } from '../../contexts/LanguageContext'; import { useRemotePlugin } from '../../hooks/UseRemotePlugin'; @@ -27,23 +29,31 @@ export default function RemoteComponent({ }>) { const containerRef = useRef(null); - const componentFn = useRemotePlugin({ - context, - source, - defaultFunctionName, - containerRef - }); + const { componentFn, errorMsg, exportName, pluginContext, remountKey } = + useRemotePlugin({ + context, + source, + defaultFunctionName, + containerRef + }); return componentFn ? ( - - - {componentFn(context)} - - + + + {componentFn(pluginContext)} + + + ) : errorMsg ? ( + } + > + {errorMsg} + ) : ( -
+ +
+ ); } diff --git a/src/frontend/src/hooks/UseRemotePlugin.tsx b/src/frontend/src/hooks/UseRemotePlugin.tsx index 64cbd64be909..187a8baeb2f1 100644 --- a/src/frontend/src/hooks/UseRemotePlugin.tsx +++ b/src/frontend/src/hooks/UseRemotePlugin.tsx @@ -1,35 +1,40 @@ 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 LegacyRemoteRenderFn = ( +type LegacyPluginEntryFn = ( container: HTMLDivElement, ctx: InvenTreePluginContext ) => void; -type RemoteComponent = (ctx: InvenTreePluginContext) => React.ReactElement; +type PluginEntryFn = (ctx: InvenTreePluginContext) => ReactElement; -interface UseRemotePluginProps { +type UseRemotePluginOptions = { context: InvenTreePluginContext; source: string; defaultFunctionName: string; containerRef: React.RefObject; -} +}; -interface UsePluginSourceProps { +type UsePluginSourceOptions = { source: string; defaultFunctionName?: string; -} +}; -type RemoteModule = { - url: string; - mod: Record; +type UseRemotePluginReturn = { + componentFn: PluginEntryFn | null; + errorMsg: string | null; + exportName: string; + pluginContext: InvenTreePluginContext; + remountKey: number; }; function usePluginSource({ source, defaultFunctionName -}: UsePluginSourceProps) { +}: UsePluginSourceOptions) { const { getHost } = useLocalState.getState(); const { moduleUrl, exportName } = useMemo(() => { @@ -45,85 +50,108 @@ function usePluginSource({ 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 -}: UseRemotePluginProps): RemoteComponent | null { +}: UseRemotePluginOptions): UseRemotePluginReturn { const { moduleUrl, exportName } = usePluginSource({ source, defaultFunctionName }); - const [remoteModule, setRemoteModule] = useState(null); - - const hmrSetModule = useCallback((newRemoteModule: RemoteModule) => { - setRemoteModule((prevRemoteModule) => { - const prevUrl = new URL(prevRemoteModule?.url ?? ''); - const newUrl = new URL(newRemoteModule.url); - if (prevUrl.pathname === newUrl.pathname) { - return newRemoteModule; - } - return prevRemoteModule; - }); - }, []); + + 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; - async function load() { - const _mod = await import(/* @vite-ignore */ moduleUrl); - - if (!cancelled) { - setRemoteModule({ mod: _mod, url: moduleUrl }); + 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}`); + } } - } + }; - load(); + loadModule(); return () => { cancelled = true; }; }, [moduleUrl]); - const legacyRenderFn = useMemo(() => { - if (!remoteModule) return null; - - const mod = remoteModule.mod; - - const func = mod[exportName]; - - if (typeof func === 'function' && func.length === 2) { - return func as LegacyRemoteRenderFn; - } - - return null; - }, [remoteModule, exportName]); - - const componentFn = useMemo(() => { - if (!remoteModule) return null; - - const mod = remoteModule.mod; + const [legacyRenderFn, componentFn, error] = useMemo(() => { + if (!remoteModule) return [null, null, null]; - const func = mod[exportName]; + let err: string | null = null; + const func = remoteModule[exportName]; - if (typeof func === 'function' && func.length === 1) { - return func as RemoteComponent; + 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; + return [null, null, err]; }, [remoteModule, exportName]); useEffect(() => { if (legacyRenderFn && containerRef.current) { + containerRef.current.innerHTML = ''; legacyRenderFn(containerRef.current, context); - (window as any).__plugin_hmr_reload = hmrSetModule; + + if (hasHmr) getHmrCallbacks(moduleUrl)?.add(hmrSetModule); } return () => { - delete (window as any).__plugin_hmr_reload; + if (hasHmr) getHmrCallbacks(moduleUrl)?.delete(hmrSetModule); }; - }, [legacyRenderFn, context, hmrSetModule]); - - return componentFn; + }, [moduleUrl, legacyRenderFn, context, hmrSetModule]); + + return { + componentFn: componentFn, + errorMsg: error ?? errorMsg, + exportName: exportName, + pluginContext: { ...context, reloadContent: reloadContent }, + remountKey: reloadVersion + }; } From a4526ed842a00b0cb8db04b8603c4bcb8acf1fa1 Mon Sep 17 00:00:00 2001 From: xhivo Date: Wed, 3 Jun 2026 16:49:43 +0200 Subject: [PATCH 5/9] Update docs --- docs/docs/plugins/creator.md | 4 ++-- docs/docs/plugins/walkthrough.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) 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(), From b7107e7847c04353fc91b24e9341978cc6c348b9 Mon Sep 17 00:00:00 2001 From: xhivo Date: Wed, 3 Jun 2026 16:50:09 +0200 Subject: [PATCH 6/9] Update CHANGELOG --- src/frontend/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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: From a5d44f91abe5f7662a0f7ec5cda732370f1bd487 Mon Sep 17 00:00:00 2001 From: xhivo Date: Wed, 3 Jun 2026 19:39:30 +0200 Subject: [PATCH 7/9] 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. --- .../components/plugins/RemoteComponent.tsx | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/frontend/src/components/plugins/RemoteComponent.tsx b/src/frontend/src/components/plugins/RemoteComponent.tsx index 5e9410d62538..ad01ac30d7bc 100644 --- a/src/frontend/src/components/plugins/RemoteComponent.tsx +++ b/src/frontend/src/components/plugins/RemoteComponent.tsx @@ -1,12 +1,11 @@ import { t } from '@lingui/core/macro'; -import { Alert, Text } from '@mantine/core'; +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 { IconExclamationCircle } from '@tabler/icons-react'; -import { LanguageContext } from '../../contexts/LanguageContext'; import { useRemotePlugin } from '../../hooks/UseRemotePlugin'; /** @@ -37,23 +36,29 @@ export default function RemoteComponent({ containerRef }); - return componentFn ? ( - - - {componentFn(pluginContext)} - - - ) : errorMsg ? ( - } - > - {errorMsg} - + const content = componentFn ? ( + componentFn(pluginContext) ) : ( - -
- +
+ ); + + return ( + + {errorMsg && ( + } + > + {errorMsg} + + )} + + {content} + + ); } From 67d00db6ee3a435b35c390d544de86db42614383 Mon Sep 17 00:00:00 2001 From: xhivo Date: Thu, 4 Jun 2026 04:00:32 +0200 Subject: [PATCH 8/9] Fix incorrect import.meta.hot access --- src/frontend/src/hooks/UseRemotePlugin.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/hooks/UseRemotePlugin.tsx b/src/frontend/src/hooks/UseRemotePlugin.tsx index 187a8baeb2f1..8d0460cfe448 100644 --- a/src/frontend/src/hooks/UseRemotePlugin.tsx +++ b/src/frontend/src/hooks/UseRemotePlugin.tsx @@ -57,7 +57,7 @@ function getHmrCallbacks(url: string) { return w.__plugin_hmr_callbacks[url]; } -const hasHmr = import.meta?.hot !== undefined; +const hasHmr = import.meta.hot !== undefined; export function useRemotePlugin({ context, From 7485ec870e4f756ea17b758396cd0b295bfeb94f Mon Sep 17 00:00:00 2001 From: xhivo Date: Thu, 4 Jun 2026 14:59:03 +0200 Subject: [PATCH 9/9] Update Playwright test to match UI text changes --- src/frontend/tests/pui_plugins.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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