From 20309146aa9ebcbd9f072c36c1521964d9fab76e Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 7 Jun 2026 23:56:26 +1000 Subject: [PATCH] [plugin] HMR lib hooks (#12108) * Expose HMR plugin * Expose function for localizing a plugin component * Update npm version * better docs * Plugin provides i18n instance * Expose HMR plugin on different path * Bump version (again) * Ensure HMR plugin is properly built * Bump (again) * Specify callback function * Bump package version * Improved docstrings * Stricter type hinting --- src/frontend/CHANGELOG.md | 26 ++++ src/frontend/lib/index.ts | 6 + .../lib/plugin/InventreeHmrPlugin.tsx | 40 +++++++ .../lib/plugin/LocalizedComponent.tsx | 113 ++++++++++++++++++ src/frontend/package.json | 5 +- src/frontend/vite.lib.config.ts | 6 +- 6 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/frontend/lib/plugin/InventreeHmrPlugin.tsx create mode 100644 src/frontend/lib/plugin/LocalizedComponent.tsx diff --git a/src/frontend/CHANGELOG.md b/src/frontend/CHANGELOG.md index c9c55f7a16..d9144e2b26 100644 --- a/src/frontend/CHANGELOG.md +++ b/src/frontend/CHANGELOG.md @@ -2,6 +2,32 @@ This file contains historical changelog information for the InvenTree UI components library. +### 1.4.5 - June 2026 + +Fixes callback signature for `` to allow for an optional `loadLocale` function to be passed in, which is used to dynamically load locale messages for the plugin. + +### 1.4.4 - June 2026 + +Fixes bundling issues associated with the `InventreeHmrPlugin` plugin function. + +### 1.4.3 - June 2026 + +Expose the `InventreeHmrPlugin` on a different path (`@inventreedb/ui/vite`) to avoid vite bundling issues. + +### 1.4.2 - June 2026 + +Fixes a bug in the `LocalizedComponent` function + +### 1.4.1 - June 2026 + +### HMR Support + +Adds support for React Fast Refresh in plugin development. This allows for a much smoother development experience when working on UI plugins, as changes to React components will now trigger a component-level update rather than a full page reload. + +### Localized Components + +Exposes a new `LocalizedComponent` function, which can be used to create React components that are automatically localized using the InvenTree server's localization system. + ### 1.4.0 - May 2026 #### Version Numbering diff --git a/src/frontend/lib/index.ts b/src/frontend/lib/index.ts index 0dfb4f4742..25b901848d 100644 --- a/src/frontend/lib/index.ts +++ b/src/frontend/lib/index.ts @@ -153,3 +153,9 @@ export { useStoredTableState } from './states/StoredTableState'; export { useLocalLibState } from './states/LocalLibState'; + +// Plugin development utilities and hooks +export { + default as LocalizedComponent, + type LocaleLoader +} from './plugin/LocalizedComponent'; diff --git a/src/frontend/lib/plugin/InventreeHmrPlugin.tsx b/src/frontend/lib/plugin/InventreeHmrPlugin.tsx new file mode 100644 index 0000000000..c5b3ea9146 --- /dev/null +++ b/src/frontend/lib/plugin/InventreeHmrPlugin.tsx @@ -0,0 +1,40 @@ +import type { Plugin } from 'vite'; + +/** + * Vite plugin which enables hot module replacement (HMR) for InvenTree plugin development. + * + * This is for use with the InvenTree plugin creator tool, + * allowing frontend plugin code to be "live reloaded" during development. + */ +export default function InventreeHmrPlugin(): Plugin { + const fileRegex = /\.(js|jsx|ts|tsx)(\?|$)/; + + const hmrBlock = [ + '', + '// __inventree_hmr_injected__', + 'if (import.meta.hot) {', + ' import.meta.hot.accept((newModule) => {', + ' const key = new URL(import.meta.url).origin + new URL(import.meta.url).pathname;', + ' window.__plugin_hmr_callbacks?.[key]?.forEach(callback => {', + ' callback(newModule);', + ' });', + ' })', + '}' + ]; + + return { + name: 'inventree-hmr-plugin', + enforce: 'post', + + transform(code, id) { + if (!fileRegex.test(id)) return; + if (id.includes('node_modules')) return; + if (code.includes('__inventree_hmr_injected__')) return; + + return { + code: code + hmrBlock.join('\n'), + map: null + }; + } + }; +} diff --git a/src/frontend/lib/plugin/LocalizedComponent.tsx b/src/frontend/lib/plugin/LocalizedComponent.tsx new file mode 100644 index 0000000000..4a86956b3a --- /dev/null +++ b/src/frontend/lib/plugin/LocalizedComponent.tsx @@ -0,0 +1,113 @@ +import type { I18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { Skeleton } from '@mantine/core'; +import { useEffect, useState } from 'react'; + +/* + * To dynamically load locale messages from a plugin context, + * the plugin MUST supply a callback function which can be used to load the locale messages for the plugin. + * This is because the plugin frontend code is built separately from the main frontend, + * and so cannot directly import locale messages from the main frontend. + * + * Refer to the inventree-plugin-creator tool for an example of how to use this component in a plugin context. + */ +export type LocaleLoader = (locale: string) => Promise; + +async function tryLoadLocale( + locale: string, + loader: LocaleLoader +): Promise { + try { + return await loader(locale); + } catch (error) { + console.warn(`Failed to load locale ${locale}`); + return null; + } +} + +/** + * @param i18n - The i18n instance from the plugin context + * @param locale - The current locale to load + * @param loader - The callback function to load the locale messages for the plugin + * @returns A React component which will load the locale messages and render the children once loaded + */ +async function loadPluginLocale( + i18n: I18n, + locale: string, + loader: LocaleLoader +) { + let messages = null; + + messages = await tryLoadLocale(locale, loader); + + if (!messages && locale.includes('-')) { + const fallbackLocale = locale.split('-')[0]; + console.debug( + `Locale ${locale} not found, trying fallback locale ${fallbackLocale}` + ); + messages = await tryLoadLocale(fallbackLocale, loader); + } + + if (!messages && locale.includes('_')) { + const fallbackLocale = locale.split('_')[0]; + console.debug( + `Locale ${locale} not found, trying fallback locale ${fallbackLocale}` + ); + messages = await tryLoadLocale(fallbackLocale, loader); + } + + if (!messages && locale !== 'en') { + console.debug(`Locale ${locale} not found, trying fallback locale en`); + messages = await tryLoadLocale('en', loader); + } + + if (messages?.messages) { + i18n.load(locale, messages.messages); + i18n.activate(locale); + } else { + console.error(`Failed to load any locale for ${locale}`); + } +} + +// A default locale loader which can be used if the plugin does not supply its own loader function +// Note: This will return null, as the plugin is expected to supply its own loader function which can load the locale messages for the plugin +const defaultLocaleLoader: LocaleLoader = async (_locale: string) => null; + +/** + * Wrapper function for a plugin-defined component which needs to support dynamic locale loading. + * + * This is primarily designed for usage by the InvenTree plugin creator tool + * + * @param i18n - The i18n instance from the plugin context + * @param locale - The current locale to load + * @param loadLocale - The callback function to load the locale messages for the plugin + * @param children - The child components to render once the locale is loaded + */ +export default function LocalizedComponent({ + i18n, + locale, + loadLocale, + children +}: { + i18n: I18n; + locale: string; + loadLocale?: LocaleLoader; + children: React.ReactNode; +}) { + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + setLoaded(false); + loadPluginLocale(i18n, locale, loadLocale ?? defaultLocaleLoader).then( + () => { + setLoaded(true); + } + ); + }, [i18n, locale, loadLocale]); + + return loaded ? ( + {children} + ) : ( + + ); +} diff --git a/src/frontend/package.json b/src/frontend/package.json index c1748d8d16..6f9236c80a 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@inventreedb/ui", "description": "UI components for the InvenTree project", - "version": "1.4.0", + "version": "1.4.5", "private": false, "type": "module", "license": "MIT", @@ -11,7 +11,8 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { - ".": "./dist/index.js" + ".": "./dist/index.js", + "./vite": "./dist/plugin/InventreeHmrPlugin.js" }, "files": [ "dist", diff --git a/src/frontend/vite.lib.config.ts b/src/frontend/vite.lib.config.ts index 4a37ab6363..2bc84fd2f8 100644 --- a/src/frontend/vite.lib.config.ts +++ b/src/frontend/vite.lib.config.ts @@ -52,7 +52,11 @@ export default defineConfig((cfg) => }, lib: { entry: { - index: resolve(__dirname, 'lib/index.ts') + index: resolve(__dirname, 'lib/index.ts'), + 'plugin/InventreeHmrPlugin': resolve( + __dirname, + 'lib/plugin/InventreeHmrPlugin.tsx' + ) }, name: 'InvenTree', formats: ['es']